Skip to main content

napparent_tabular/
pipeline.rs

1//! End-to-end chunked tabular transform driver.
2
3use 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
38/// Chunked pipeline: preprocess, pair aggregation, apply — returns one output batch per input batch.
39pub 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
199/// Chunked pipeline: preprocess passes, pair aggregation, then apply per batch.
200///
201/// # Example
202///
203/// ```
204/// use arrow::array::{Float32Array, StringArray};
205/// use arrow::datatypes::{DataType, Field, Schema};
206/// use arrow::record_batch::RecordBatch;
207/// use napparent_tabular::{BinDepth, TransformConfig, transform_record_batches};
208/// use std::sync::Arc;
209///
210/// let id = Arc::new(StringArray::from(vec!["a", "b"]));
211/// let feat = Arc::new(Float32Array::from(vec![1.0_f32, 20.0]));
212/// let target = Arc::new(Float32Array::from(vec![0.5_f32, 1.5]));
213/// let schema = Arc::new(Schema::new(vec![
214///     Field::new("id", DataType::Utf8, false),
215///     Field::new("feat", DataType::Float32, false),
216///     Field::new("target", DataType::Float32, false),
217/// ]));
218/// let batch = RecordBatch::try_new(schema, vec![id, feat, target]).unwrap();
219/// let config = TransformConfig::new(BinDepth::new(4));
220/// let out = transform_record_batches(&[batch], "target", &["target".into()], &config).unwrap();
221/// assert_eq!(out.num_rows(), 2);
222/// ```
223pub 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}