tenferro-ad 0.3.0

Eager runtime, eager tensors, and traced AD extension traits for tenferro.
use std::collections::HashMap;
use std::sync::Arc;

use computegraph::graph::GraphBuilder;
use tenferro_ad::TracedTensorAdExt;
use tenferro_ops::input_key::TensorInputKey;
use tenferro_ops::std_tensor_op::StdTensorOp;
use tenferro_runtime::error::Error;
use tenferro_runtime::{
    ad_support::{tensor_from_parts, ConstraintScopeTransfer, RetainedValue, TracedTensorParts},
    DType, GraphCompiler, ScopedExecutionOutcome, ScopedReadInputs, SymDim, Tensor, TensorView,
    TracedTensor,
};

use crate::support::{cpu_runtime, run_compiled_one};

#[test]
fn cpu_scoped_execution_returns_borrowed_identity_without_copy() {
    let x = TracedTensor::input_symbolic_shape(DType::F64, 1).unwrap();
    let mut compiler = GraphCompiler::new();
    let program = compiler
        .compile_with_input_specs(&x, &[(&x, DType::F64, &[2])])
        .unwrap();
    let runtime = cpu_runtime();
    let input = Tensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap();
    let view = match &input {
        Tensor::F64(tensor) => TensorView::F64(tensor.as_view()),
        other => panic!("unexpected input dtype: {:?}", other.dtype()),
    };
    let outcome = runtime
        .execute_scoped_read_only(&program, ScopedReadInputs::new(vec![view]))
        .unwrap();
    let ScopedExecutionOutcome::Completed(bundle) = outcome else {
        panic!("CPU scoped execution did not complete")
    };
    match bundle.output(0).unwrap() {
        tenferro_runtime::OutputRef::Tensor(TensorView::F64(view)) => {
            assert_eq!(view.as_slice().unwrap(), &[1.0, 2.0]);
        }
        tenferro_runtime::OutputRef::Metadata(_) => panic!("unexpected metadata output"),
        _ => panic!("unexpected output dtype"),
    }
}

#[test]
fn runtime_runs_compiled_single_output_program() {
    let x = TracedTensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap();
    let y = (&x + &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let program = compiler.compile(&y).unwrap();
    let executor = cpu_runtime();

    let out = run_compiled_one(&executor, &program, &[]).unwrap();

    assert_eq!(out.as_slice::<f64>().unwrap(), &[2.0, 4.0]);
}

#[test]
fn runtime_runs_compiled_multi_output_program() {
    let x = TracedTensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap();
    let sum = (&x + &x).unwrap();
    let product = (&x * &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let program = compiler.compile_many(&[&sum, &product]).unwrap();
    let executor = cpu_runtime();

    let outputs = executor.run_compiled(&program, &[]).unwrap();

    assert_eq!(outputs.len(), 2);
    assert_eq!(outputs[0].as_slice::<f64>().unwrap(), &[2.0, 4.0]);
    assert_eq!(outputs[1].as_slice::<f64>().unwrap(), &[1.0, 4.0]);
}

#[test]
fn checkpoint_uses_explicit_compiler_and_runtime() {
    let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
    let mut y = (&x * &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let executor = cpu_runtime();
    y.checkpoint(&mut compiler, &executor).unwrap();

    let program = compiler.compile(&y).unwrap();
    let out = run_compiled_one(&executor, &program, &[]).unwrap();
    assert_eq!(out.as_slice::<f64>().unwrap(), &[9.0]);
}

#[test]
fn checkpoint_reuses_existing_cached_data_without_recompiling() {
    let x = TracedTensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap();
    let mut y = (&x + &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let executor = cpu_runtime();
    y.checkpoint(&mut compiler, &executor).unwrap();

    y.checkpoint(&mut compiler, &executor).unwrap();

    let program = compiler.compile(&y).unwrap();
    let out = run_compiled_one(&executor, &program, &[]).unwrap();
    assert_eq!(out.as_slice::<f64>().unwrap(), &[2.0, 4.0]);
}

#[test]
fn checkpoint_gradient_runs_through_runtime() {
    let x = TracedTensor::from_vec_col_major(vec![], vec![2.0_f64]).unwrap();
    let mut y = (&x * &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let executor = cpu_runtime();
    y.checkpoint(&mut compiler, &executor).unwrap();

    let z = (&y * &y).unwrap();
    let grad = z.grad(&x).unwrap();
    let program = compiler.compile(&grad).unwrap();
    let out = run_compiled_one(&executor, &program, &[]).unwrap();

    assert_eq!(out.as_slice::<f64>().unwrap(), &[32.0]);
}

#[test]
fn runtime_validates_runtime_bindings() {
    let x = TracedTensor::input_symbolic_shape(DType::F64, 1).unwrap();
    let y = (&x + &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let program = compiler
        .compile_with_input_specs(&y, &[(&x, DType::F64, &[3])])
        .unwrap();
    let executor = cpu_runtime();

    let ok = Tensor::from_vec_col_major(vec![3], vec![1.0_f64, 2.0, 3.0]).unwrap();
    let out = run_compiled_one(&executor, &program, &[&ok]).unwrap();
    assert_eq!(out.as_slice::<f64>().unwrap(), &[2.0, 4.0, 6.0]);

    let wrong_shape = Tensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap();
    let err = run_compiled_one(&executor, &program, &[&wrong_shape]).unwrap_err();
    assert!(format!("{err}").contains("shape"));
}

#[test]
fn runtime_rejects_invalid_ordered_inputs() {
    let x = TracedTensor::input_symbolic_shape(DType::F64, 1).unwrap();
    let y = (&x + &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let program = compiler
        .compile_with_input_specs(&y, &[(&x, DType::F64, &[2])])
        .unwrap();
    let executor = cpu_runtime();

    let bound = Tensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap();
    let err = run_compiled_one(&executor, &program, &[&bound, &bound]).unwrap_err();
    assert!(
        matches!(
            err,
            Error::GraphInputCountMismatch {
                expected: 1,
                actual: 2
            }
        ),
        "got {err:?}"
    );

    let wrong_dtype = Tensor::from_vec_col_major(vec![2], vec![1.0_f32, 2.0]).unwrap();
    let err = run_compiled_one(&executor, &program, &[&wrong_dtype]).unwrap_err();
    assert!(
        matches!(
            err,
            Error::PlaceholderDtypeMismatch {
                expected: DType::F64,
                actual: DType::F32
            }
        ),
        "got {err:?}"
    );

    let err = run_compiled_one(&executor, &program, &[]).unwrap_err();
    assert!(
        matches!(err, Error::UnboundPlaceholder { .. }),
        "got {err:?}"
    );
}

#[test]
fn runtime_cache_stats_are_separate_from_compiler_stats() {
    let x = TracedTensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap();
    let y = (&x + &x).unwrap();

    let mut compiler = GraphCompiler::new();
    let program = compiler.compile(&y).unwrap();
    let executor = cpu_runtime();
    let _ = run_compiled_one(&executor, &program, &[]).unwrap();

    assert_eq!(compiler.cache_stats().entries, 0);
    assert_eq!(executor.cache_stats().unwrap().extensions.entries, 0);
    assert!(
        executor.cache_stats().unwrap().prepared_plans.entries > 0,
        "runtime execution should populate the prepared-plan cache"
    );
}

#[test]
fn runtime_cache_controls_are_available() {
    let executor = cpu_runtime();

    let stats = executor.cache_stats().unwrap();
    assert_eq!(stats.extensions.entries, 0);
    assert_eq!(stats.engines.entries, 0);

    executor.clear_caches().unwrap();

    let stats = executor.cache_stats().unwrap();
    assert_eq!(stats.extensions.entries, 0);
    assert_eq!(stats.engines.entries, 0);
    assert_eq!(stats.prepared_plans.entries, 0);
}

#[test]
fn graph_compiler_rejects_unbound_tangent_even_when_primal_has_default() {
    let primal_key = TensorInputKey::User { id: 1234 };
    let tangent_key = primal_key.tangent_of(7);
    let mut builder = GraphBuilder::<StdTensorOp>::new();
    let tangent_id = builder.add_input(tangent_key);
    builder.set_outputs(vec![tangent_id]);
    let graph = Arc::new(builder.build());

    let primal = Arc::new(RetainedValue::from_tensor(
        Tensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0]).unwrap(),
    ));
    let output = tensor_from_parts(TracedTensorParts {
        rank: 1,
        dtype: DType::F64,
        graph,
        val: tangent_id,
        data: None,
        shape_hint: Some(vec![SymDim::from(2usize)]),
        inputs_map: Arc::new(HashMap::from([(primal_key, primal)])),
        extra_roots: Vec::new(),
        checkpoint_chain: None,
        metadata_scopes: Vec::new(),
        constraint_scope_transfer: ConstraintScopeTransfer::empty(),
    });

    let err = GraphCompiler::new().compile(&output).unwrap_err();
    assert!(
        matches!(err, Error::UnboundPlaceholder { ref input_key } if input_key.contains("Tangent")),
        "dangling tangent inputs must not be silently bound from primal defaults: {err:?}"
    );
}