1use crate::aux::feature_names::FeatureNameKind;
20use legume_numeric::matrix::traits::IoOps;
21use nalgebra::DMatrix;
22use rustc_hash::{FxHashMap, FxHashSet};
23
24pub struct FrozenFeatureHost {
33 pub e_feat: DMatrix<f32>,
35 pub b_feat: Vec<f32>,
37 pub keep_target_indices: Vec<usize>,
40 pub keep_src_indices: Vec<usize>,
44 pub src_e_feat: DMatrix<f32>,
48 pub src_names: Vec<Box<str>>,
49 pub n_src: usize,
56 pub h: usize,
57}
58
59pub type SourceNameMap<'a> = &'a dyn Fn(&str) -> Box<str>;
61
62pub struct FrozenLoadArgs<'a> {
63 pub dictionary_path: &'a str,
66 pub bias_path: Option<&'a str>,
70 pub target_feature_names: &'a [Box<str>],
74 pub name_kind: FeatureNameKind,
79 pub source_name_map: Option<SourceNameMap<'a>>,
84}
85
86pub fn load_frozen_feature_host(args: FrozenLoadArgs) -> anyhow::Result<FrozenFeatureHost> {
87 let dict = <DMatrix<f32> as IoOps>::from_parquet(args.dictionary_path)?;
88 let n_src = dict.rows.len();
89 let h = dict.mat.ncols();
90 anyhow::ensure!(
91 h > 0 && dict.mat.nrows() == n_src,
92 "{}: malformed dictionary (rows={}, mat dims={}x{})",
93 args.dictionary_path,
94 n_src,
95 dict.mat.nrows(),
96 h
97 );
98
99 let src_bias: Vec<f32> = match args.bias_path {
100 None => vec![0.0; n_src],
101 Some(p) => {
102 let bias = <DMatrix<f32> as IoOps>::from_parquet(p)?;
103 anyhow::ensure!(
104 bias.rows == dict.rows,
105 "{} row names disagree with {} (both files must come from the same training run)",
106 p,
107 args.dictionary_path
108 );
109 anyhow::ensure!(
110 bias.mat.ncols() == 1,
111 "{}: expected 1 data column (bias), got {}",
112 p,
113 bias.mat.ncols()
114 );
115 (0..n_src).map(|i| bias.mat[(i, 0)]).collect()
116 }
117 };
118
119 let src_names: Vec<Box<str>> = match args.source_name_map {
120 Some(f) => dict.rows.iter().map(|n| f(n)).collect(),
121 None => dict.rows,
122 };
123 let mut src_by_canon: FxHashMap<Box<str>, usize> = FxHashMap::default();
124 let mut src_dupes = 0usize;
125 for (i, name) in src_names.iter().enumerate() {
126 let canon = args.name_kind.canonicalize(name);
127 if let std::collections::hash_map::Entry::Vacant(e) = src_by_canon.entry(canon) {
129 e.insert(i);
130 } else {
131 src_dupes += 1;
132 }
133 }
134 if src_dupes > 0 {
135 log::warn!(
136 "{}: {} source rows had duplicate canonical names — kept first occurrence",
137 args.dictionary_path,
138 src_dupes
139 );
140 }
141
142 let mut keep_target_indices = Vec::new();
143 let mut keep_src_indices = Vec::new();
144 for (target_i, name) in args.target_feature_names.iter().enumerate() {
145 let canon = args.name_kind.canonicalize(name);
146 if let Some(&src_i) = src_by_canon.get(&canon) {
147 keep_target_indices.push(target_i);
148 keep_src_indices.push(src_i);
149 }
150 }
151 anyhow::ensure!(
152 !keep_target_indices.is_empty(),
153 "No feature names matched between {} (n={}) and target axis (n={}) under {:?} \
154 — check the gene-name kind (Exact / Gene / Locus / Mixed) and source axis",
155 args.dictionary_path,
156 n_src,
157 args.target_feature_names.len(),
158 args.name_kind
159 );
160
161 let unique_src_used: FxHashSet<usize> = keep_src_indices.iter().copied().collect();
162 let channelized_unmatched = src_names
167 .iter()
168 .enumerate()
169 .filter(|(i, r)| {
170 !unique_src_used.contains(i) && crate::aux::feature_rows::parse_feature_row(r).is_some()
171 })
172 .count();
173 if channelized_unmatched > 0 {
174 log::warn!(
175 "{}: {} unmatched source rows carry the channelized row grammar — is this a raw gene dictionary, or a channelized/co-embedding output?",
176 args.dictionary_path,
177 channelized_unmatched
178 );
179 }
180 log::info!(
181 "Frozen feature side from {}: {}/{} target features matched (H={}, {} of {} source rows reused, kind={:?})",
182 args.dictionary_path,
183 keep_target_indices.len(),
184 args.target_feature_names.len(),
185 h,
186 unique_src_used.len(),
187 n_src,
188 args.name_kind
189 );
190
191 let k = keep_target_indices.len();
192 let mut e_feat = DMatrix::<f32>::zeros(k, h);
193 let mut b_feat = Vec::with_capacity(k);
194 for (out_i, &src_i) in keep_src_indices.iter().enumerate() {
195 for j in 0..h {
196 e_feat[(out_i, j)] = dict.mat[(src_i, j)];
197 }
198 b_feat.push(src_bias[src_i]);
199 }
200
201 Ok(FrozenFeatureHost {
202 e_feat,
203 b_feat,
204 keep_target_indices,
205 keep_src_indices,
206 src_e_feat: dict.mat,
207 src_names,
208 n_src,
209 h,
210 })
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use legume_numeric::matrix::traits::IoOps;
217
218 fn write_test_parquet(
219 path: &str,
220 rows: &[&str],
221 row_axis: &str,
222 cols: &[&str],
223 data: &DMatrix<f32>,
224 ) {
225 let row_names: Vec<Box<str>> = rows.iter().map(|s| (*s).into()).collect();
226 let col_names: Vec<Box<str>> = cols.iter().map(|s| (*s).into()).collect();
227 data.to_parquet_with_names(path, (Some(&row_names), Some(row_axis)), Some(&col_names))
228 .unwrap();
229 }
230
231 #[test]
232 fn strict_intersection_drops_unmatched_and_preserves_target_order() {
233 let dir = tempfile::tempdir().unwrap();
234 let dict_path = dir.path().join("d.parquet").to_str().unwrap().to_string();
235
236 let src = DMatrix::<f32>::from_row_slice(
238 4,
239 3,
240 &[
241 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, ],
246 );
247 write_test_parquet(
248 &dict_path,
249 &["TGFB1", "MYC", "ENSG_DROP", "TP53"],
250 "gene",
251 &["h0", "h1", "h2"],
252 &src,
253 );
254
255 let target: Vec<Box<str>> = ["FOO", "TP53", "TGFB1", "BAR", "MYC"]
257 .iter()
258 .map(|s| (*s).into())
259 .collect();
260
261 let host = load_frozen_feature_host(FrozenLoadArgs {
262 dictionary_path: &dict_path,
263 bias_path: None,
264 target_feature_names: &target,
265 name_kind: FeatureNameKind::Exact,
266 source_name_map: None,
267 })
268 .unwrap();
269
270 assert_eq!(host.keep_target_indices, vec![1, 2, 4]);
272 assert_eq!(host.h, 3);
273 assert_eq!(host.e_feat.nrows(), 3);
274 assert_eq!(host.b_feat, vec![0.0, 0.0, 0.0]);
275
276 assert_eq!(host.e_feat[(0, 0)], 10.0);
278 assert_eq!(host.e_feat[(0, 2)], 12.0);
279 assert_eq!(host.e_feat[(1, 0)], 1.0);
281 assert_eq!(host.e_feat[(2, 1)], 5.0);
283 }
284
285 #[test]
289 fn a_source_name_map_is_applied_before_matching_and_kept_in_src_names() {
290 let dir = tempfile::tempdir().unwrap();
291 let dict_path = dir.path().join("d.parquet").to_str().unwrap().to_string();
292 let src = DMatrix::<f32>::from_row_slice(2, 2, &[1.0, 2.0, 3.0, 4.0]);
293 write_test_parquet(
294 &dict_path,
295 &["TGFB1", "MYC/count/unspliced"],
296 "gene",
297 &["h0", "h1"],
298 &src,
299 );
300 let target: Vec<Box<str>> = [
301 "ENSG_TGFB1/count/spliced",
302 "ENSG_MYC/count/spliced",
303 "ENSG_MYC/count/unspliced",
304 ]
305 .iter()
306 .map(|s| (*s).into())
307 .collect();
308 let lift = |n: &str| -> Box<str> {
309 if n.contains('/') {
310 n.into()
311 } else {
312 format!("{n}/count/spliced").into()
313 }
314 };
315 let host = load_frozen_feature_host(FrozenLoadArgs {
316 dictionary_path: &dict_path,
317 bias_path: None,
318 target_feature_names: &target,
319 name_kind: FeatureNameKind::Gene { delim: '_' },
320 source_name_map: Some(&lift),
321 })
322 .unwrap();
323 assert_eq!(host.keep_target_indices, vec![0, 2]);
324 assert_eq!(host.keep_src_indices, vec![0, 1]);
325 assert_eq!(
326 host.src_names,
327 vec![
328 Box::<str>::from("TGFB1/count/spliced"),
329 Box::<str>::from("MYC/count/unspliced")
330 ]
331 );
332 assert_eq!(host.e_feat[(0, 0)], 1.0);
333 assert_eq!(host.e_feat[(1, 1)], 4.0);
334 }
335
336 #[test]
337 fn gene_canon_matches_across_delim_variants() {
338 let dir = tempfile::tempdir().unwrap();
339 let dict_path = dir.path().join("d.parquet").to_str().unwrap().to_string();
340
341 let src = DMatrix::<f32>::from_row_slice(2, 2, &[1.0, 2.0, 3.0, 4.0]);
343 write_test_parquet(
344 &dict_path,
345 &["ENSG00000105329_TGFB1", "ENSG00000141510_TP53"],
346 "gene",
347 &["h0", "h1"],
348 &src,
349 );
350 let target: Vec<Box<str>> = ["TP53", "TGFB1"].iter().map(|s| (*s).into()).collect();
351
352 let host = load_frozen_feature_host(FrozenLoadArgs {
353 dictionary_path: &dict_path,
354 bias_path: None,
355 target_feature_names: &target,
356 name_kind: FeatureNameKind::Gene { delim: '_' },
357 source_name_map: None,
358 })
359 .unwrap();
360
361 assert_eq!(host.keep_target_indices, vec![0, 1]);
362 assert_eq!(host.e_feat[(0, 0)], 3.0);
364 assert_eq!(host.e_feat[(1, 0)], 1.0);
366 }
367
368 #[test]
369 fn empty_intersection_errors() {
370 let dir = tempfile::tempdir().unwrap();
371 let dict_path = dir.path().join("d.parquet").to_str().unwrap().to_string();
372 let src = DMatrix::<f32>::from_row_slice(2, 2, &[1.0, 2.0, 3.0, 4.0]);
373 write_test_parquet(&dict_path, &["A", "B"], "gene", &["h0", "h1"], &src);
374 let target: Vec<Box<str>> = ["C", "D"].iter().map(|s| (*s).into()).collect();
375 let result = load_frozen_feature_host(FrozenLoadArgs {
376 dictionary_path: &dict_path,
377 bias_path: None,
378 target_feature_names: &target,
379 name_kind: FeatureNameKind::Exact,
380 source_name_map: None,
381 });
382 let err = match result {
383 Ok(_) => panic!("expected empty-intersection error"),
384 Err(e) => e,
385 };
386 assert!(err.to_string().contains("No feature names matched"));
387 }
388
389 #[test]
390 fn bias_loaded_when_provided() {
391 let dir = tempfile::tempdir().unwrap();
392 let dict_path = dir.path().join("d.parquet").to_str().unwrap().to_string();
393 let bias_path = dir.path().join("b.parquet").to_str().unwrap().to_string();
394 let src = DMatrix::<f32>::from_row_slice(2, 2, &[1.0, 2.0, 3.0, 4.0]);
395 write_test_parquet(&dict_path, &["A", "B"], "gene", &["h0", "h1"], &src);
396 let bias = DMatrix::<f32>::from_row_slice(2, 1, &[0.5, -0.3]);
397 write_test_parquet(&bias_path, &["A", "B"], "gene", &["bias"], &bias);
398
399 let target: Vec<Box<str>> = ["B", "A"].iter().map(|s| (*s).into()).collect();
400 let host = load_frozen_feature_host(FrozenLoadArgs {
401 dictionary_path: &dict_path,
402 bias_path: Some(&bias_path),
403 target_feature_names: &target,
404 name_kind: FeatureNameKind::Exact,
405 source_name_map: None,
406 })
407 .unwrap();
408 assert_eq!(host.b_feat, vec![-0.3, 0.5]);
410 }
411}