1use crate::activation::TransformConfig;
4use crate::aggregator::PairAggregator;
5use crate::arrow_io::{
6 batch_from_map, concat_same_schema, split_batch_views, target_as_outcomes, target_to_vec,
7 OutcomesRef,
8};
9use crate::cancel::{CancelToken, INTERRUPT_MSG};
10use crate::preprocess::PreprocessStream;
11use crate::progress::{ProgressReporter, ProgressTimer};
12use crate::table::ColumnVec;
13use arrow::datatypes::{DataType, Field, Schema};
14use arrow::record_batch::RecordBatch;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18fn check_limit(value: usize, max: Option<usize>, label: &str) -> Result<(), String> {
19 if let Some(max) = max {
20 if value > max {
21 return Err(format!("limit exceeded: {label} ({value} > {max})"));
22 }
23 }
24 Ok(())
25}
26
27fn outcomes_for_effect(outcomes: &OutcomesRef<'_>) -> Vec<f32> {
28 outcomes.to_nan0_vec()
29}
30
31fn check_cancel(cancel: Option<&CancelToken>) -> Result<(), String> {
32 if let Some(c) = cancel {
33 c.check()?;
34 }
35 Ok(())
36}
37
38pub fn transform_record_batches_chunked(
40 batches: &[RecordBatch],
41 target: &str,
42 cols_to_drop: &[String],
43 config: &TransformConfig,
44 cancel: Option<&CancelToken>,
45) -> Result<Vec<RecordBatch>, String> {
46 let mut reporter = ProgressReporter::from_verbose(config.verbose);
47 match transform_record_batches_chunked_inner(
48 batches,
49 target,
50 cols_to_drop,
51 config,
52 cancel,
53 &mut reporter,
54 ) {
55 Ok(out) => Ok(out),
56 Err(e) => {
57 if e.contains(INTERRUPT_MSG) {
58 reporter.abandon();
59 }
60 Err(e)
61 }
62 }
63}
64
65fn transform_record_batches_chunked_inner(
66 batches: &[RecordBatch],
67 target: &str,
68 cols_to_drop: &[String],
69 config: &TransformConfig,
70 cancel: Option<&CancelToken>,
71 reporter: &mut ProgressReporter,
72) -> Result<Vec<RecordBatch>, String> {
73 if batches.is_empty() {
74 return Err("no record batches".into());
75 }
76
77 let limits = &config.limits;
78 let total_timer = ProgressTimer::start();
79 let n_batches = batches.len();
80 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
81
82 check_limit(total_rows, limits.max_rows, "max_rows")?;
83
84 reporter.log(&format!(
85 "starting transform: {n_batches} batches, {total_rows} total rows"
86 ));
87
88 let (_, _, cg0) = split_batch_views(&batches[0], target, cols_to_drop)?;
89 let mut pst = PreprocessStream::new(cg0);
90
91 reporter.pass_start(1, 3, "preprocessing", n_batches);
92 let pass1 = ProgressTimer::start();
93 for (i, b) in batches.iter().enumerate() {
94 check_cancel(cancel)?;
95 let (chunk, _target, cg) = split_batch_views(b, target, cols_to_drop)?;
96 if cg != pst.col_graph {
97 return Err("inconsistent schema across chunks".into());
98 }
99 reporter.batch_tick(
100 i,
101 n_batches,
102 b.num_rows(),
103 &format!(
104 "preprocess batch {}/{} ({} rows)",
105 i + 1,
106 n_batches,
107 b.num_rows()
108 ),
109 );
110 pst.preprocess_batch(&chunk)?;
111 }
112 pst.finish_map(&config.bin_depth)?;
113 check_limit(
114 pst.cols.len(),
115 limits.max_active_columns,
116 "max_active_columns",
117 )?;
118 reporter.pass_finish(&format!(
119 "pass 1/3 done in {:.1}s: bins finished, {} active feature columns",
120 pass1.elapsed_secs(),
121 pst.cols.len()
122 ));
123
124 let column_order: Vec<String> = pst.col_graph.names.clone();
125
126 let mut agg = PairAggregator::with_activation(config.activation.clone());
127 let mut first = true;
128
129 reporter.pass_start(2, 3, "KG", n_batches);
130 let pass2 = ProgressTimer::start();
131 for (i, b) in batches.iter().enumerate() {
132 check_cancel(cancel)?;
133 let (chunk, target_col, col_graph) = split_batch_views(b, target, cols_to_drop)?;
134 let x_proc = pst.use_map_batch(&chunk)?;
135 let outcomes = target_as_outcomes(&target_col);
136
137 if first {
138 agg.initialize_inputs(&col_graph, target, &column_order)?;
139 agg.make_col_combos();
140 check_limit(agg.col_array.len(), limits.max_col_pairs, "max_col_pairs")?;
141 first = false;
142 }
143 reporter.batch_tick(
144 i,
145 n_batches,
146 b.num_rows(),
147 &format!("KG update batch {}/{}", i + 1, n_batches),
148 );
149 agg.vals_map_updating(&x_proc, &outcomes)?;
150 check_limit(
151 agg.vals_map_len(),
152 limits.max_vals_map_keys,
153 "max_vals_map_keys",
154 )?;
155 }
156 agg.finish_map();
157 reporter.pass_finish(&format!(
158 "pass 2/3 done in {:.1}s: KG finished, {} pair keys, global mean outcome {:.6}",
159 pass2.elapsed_secs(),
160 agg.vals_map_avg.len(),
161 agg.avg_outcome
162 ));
163
164 reporter.pass_start(3, 3, "transform", n_batches);
165 let pass3 = ProgressTimer::start();
166 let mut out_batches: Vec<RecordBatch> = Vec::with_capacity(n_batches);
167 for (i, b) in batches.iter().enumerate() {
168 check_cancel(cancel)?;
169 let (chunk, target_col, _cg) = split_batch_views(b, target, cols_to_drop)?;
170 let x_proc = pst.use_map_batch(&chunk)?;
171 let outcomes = target_as_outcomes(&target_col);
172 let outcomes_vec = outcomes_for_effect(&outcomes);
173 let y = target_to_vec(&target_col);
174 reporter.batch_tick(
175 i,
176 n_batches,
177 b.num_rows(),
178 &format!("transform batch {}/{}", i + 1, n_batches),
179 );
180 let nnm = agg.use_map(x_proc, y, outcomes_vec)?;
181 let schema = Arc::new(build_output_schema(&nnm, &column_order)?);
182 let batch = batch_from_map(schema, nnm)?;
183 out_batches.push(batch);
184 }
185 reporter.pass_finish(&format!("pass 3/3 done in {:.1}s", pass3.elapsed_secs()));
186
187 let total_out_rows: usize = out_batches.iter().map(|b| b.num_rows()).sum();
188 let n_cols = out_batches.first().map(|b| b.num_columns()).unwrap_or(0);
189 reporter.finish(&format!(
190 "complete in {:.1}s → {} rows × {} columns ({} output batches)",
191 total_timer.elapsed_secs(),
192 total_out_rows,
193 n_cols,
194 out_batches.len()
195 ));
196 Ok(out_batches)
197}
198
199pub fn transform_record_batches(
224 batches: &[RecordBatch],
225 target: &str,
226 cols_to_drop: &[String],
227 config: &TransformConfig,
228) -> Result<RecordBatch, String> {
229 let chunks = transform_record_batches_chunked(batches, target, cols_to_drop, config, None)?;
230 concat_same_schema(&chunks)
231}
232
233fn build_output_schema(
234 nnm: &HashMap<String, ColumnVec>,
235 column_order: &[String],
236) -> Result<Schema, String> {
237 let mut fields: Vec<Field> = Vec::new();
238 for name in column_order {
239 let col = nnm
240 .get(name)
241 .ok_or_else(|| format!("missing column {name} in output"))?;
242 let dt = match col {
243 ColumnVec::F32(_) | ColumnVec::F32Array(_) => DataType::Float32,
244 ColumnVec::Utf8(_) => DataType::Utf8,
245 };
246 fields.push(Field::new(name, dt, false));
247 }
248 for name in column_order {
249 let effect = format!("{name}_effect");
250 if nnm.contains_key(&effect) {
251 let col = nnm.get(&effect).unwrap();
252 let dt = match col {
253 ColumnVec::F32(_) | ColumnVec::F32Array(_) => DataType::Float32,
254 ColumnVec::Utf8(_) => DataType::Utf8,
255 };
256 fields.push(Field::new(&effect, dt, false));
257 }
258 }
259 if nnm.contains_key("Actuals") {
260 fields.push(Field::new("Actuals", DataType::Float32, false));
261 }
262 if nnm.contains_key("outcomes_effect") {
263 fields.push(Field::new("outcomes_effect", DataType::Float32, false));
264 }
265 Ok(Schema::new(fields))
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::activation::TransformLimits;
272 use crate::cancel::CancelToken;
273 use crate::preprocess::BinDepth;
274 use arrow::array::{Float32Array, StringArray};
275 use std::sync::Arc;
276
277 fn batch_small() -> RecordBatch {
278 let id = Arc::new(StringArray::from(vec!["a", "b"]));
279 let x = Arc::new(Float32Array::from(vec![1.0_f32, 20.0]));
280 let y = Arc::new(Float32Array::from(vec![0.5_f32, 1.5]));
281 let schema = Arc::new(Schema::new(vec![
282 Field::new("id", DataType::Utf8, false),
283 Field::new("feat", DataType::Float32, false),
284 Field::new("target", DataType::Float32, false),
285 ]));
286 RecordBatch::try_new(schema, vec![id, x, y]).unwrap()
287 }
288
289 fn run_config() -> TransformConfig {
290 TransformConfig::new(BinDepth::new(4))
291 }
292
293 #[test]
294 fn pipeline_runs() {
295 let b = batch_small();
296 let r =
297 transform_record_batches(&[b.clone(), b], "target", &["target".into()], &run_config());
298 assert!(r.is_ok());
299 let out = r.unwrap();
300 assert_eq!(out.num_rows(), 4);
301 }
302
303 #[test]
304 fn chunked_matches_concat_row_count() {
305 let b = batch_small();
306 let batches = [b.clone(), b];
307 let config = run_config();
308 let concat =
309 transform_record_batches(&batches, "target", &["target".into()], &config).unwrap();
310 let chunked =
311 transform_record_batches_chunked(&batches, "target", &["target".into()], &config, None)
312 .unwrap();
313 let chunked_rows: usize = chunked.iter().map(|b| b.num_rows()).sum();
314 assert_eq!(concat.num_rows(), chunked_rows);
315 assert_eq!(concat.num_columns(), chunked[0].num_columns());
316 assert_eq!(concat.schema(), chunked[0].schema());
317 }
318
319 #[test]
320 fn max_rows_limit_rejects() {
321 let b = batch_small();
322 let config = TransformConfig::new(BinDepth::new(4)).with_limits(TransformLimits {
323 max_rows: Some(1),
324 ..TransformLimits::default()
325 });
326 let err =
327 transform_record_batches_chunked(&[b], "target", &["target".into()], &config, None)
328 .unwrap_err();
329 assert!(err.contains("max_rows"));
330 }
331
332 #[test]
333 fn cancel_token_stops_between_batches() {
334 let b = batch_small();
335 let token = CancelToken::none();
336 token.request_stop();
337 let config = run_config();
338 let err = transform_record_batches_chunked(
339 &[b.clone(), b],
340 "target",
341 &["target".into()],
342 &config,
343 Some(&token),
344 )
345 .unwrap_err();
346 assert!(err.contains("interrupted"));
347 }
348}