phasesmith-workflows 0.1.0

Application-neutral native workflows for PhaseSmith
Documentation
//! Native parameter and constraint semantic contracts.

use std::process::Command;

use phasesmith_workflows::{
    AffineConstraint, Constraint, ConstraintError, ConstraintTransform, FixedConstraint,
    LinearConstraint, LinearTerm, ParameterBounds, ParameterError, ParameterKey, ParameterSet,
    ParameterSpec,
};

#[test]
fn fixed_and_affine_constraints_expand_and_differentiate_in_stable_order() {
    let (parameters, keys) = parameter_set();
    let transform = ConstraintTransform::new(
        parameters,
        vec![
            Constraint::Fixed(FixedConstraint::new(keys[1].clone(), -2.0e-4).unwrap()),
            Constraint::Affine(
                AffineConstraint::new(keys[2].clone(), keys[0].clone(), 0.5, 1.0e-5).unwrap(),
            ),
        ],
    )
    .unwrap();
    assert_eq!(transform.free_keys(), [keys[0].clone(), keys[3].clone()]);
    assert_eq!(transform.pack().unwrap(), [2.0, 1.0]);
    let values = transform.unpack(&[3.0, 1.5], false).unwrap();
    assert_close(values[&keys[0]], 3.0e-4, 1.0e-18);
    assert_close(values[&keys[1]], -2.0e-4, 1.0e-18);
    assert_close(values[&keys[2]], 1.6e-4, 1.0e-18);
    assert_close(values[&keys[3]], 1.5, 1.0e-15);

    let derivative = transform.derivative_matrix().unwrap();
    assert_eq!(derivative.rows, 4);
    assert_eq!(derivative.columns, 2);
    assert_eq!(derivative.row(0).unwrap(), [1.0e-4, 0.0]);
    assert_eq!(derivative.row(1).unwrap(), [0.0, 0.0]);
    assert_eq!(derivative.row(2).unwrap(), [0.5e-4, 0.0]);
    assert_eq!(derivative.row(3).unwrap(), [0.0, 1.0]);
}

#[test]
fn multi_source_constraints_chain_exact_rows() {
    let (parameters, keys) = parameter_set();
    let linear = LinearConstraint::new(
        keys[2].clone(),
        vec![
            LinearTerm::new(keys[0].clone(), 0.5).unwrap(),
            LinearTerm::new(keys[1].clone(), 0.2).unwrap(),
        ],
        4.0e-5,
    )
    .unwrap();
    let transform = ConstraintTransform::new(parameters, vec![Constraint::Linear(linear)]).unwrap();
    assert_eq!(
        transform.free_keys(),
        [keys[0].clone(), keys[1].clone(), keys[3].clone()]
    );
    let values = transform.unpack(&transform.pack().unwrap(), false).unwrap();
    assert_close(values[&keys[2]], 1.2e-4, 1.0e-18);
    let derivative = transform.derivative_matrix().unwrap();
    assert_eq!(derivative.row(0).unwrap(), [1.0e-4, 0.0, 0.0]);
    assert_eq!(derivative.row(1).unwrap(), [0.0, 1.0e-4, 0.0]);
    assert_eq!(derivative.row(2).unwrap(), [0.5e-4, 0.2e-4, 0.0]);
    assert_eq!(derivative.row(3).unwrap(), [0.0, 0.0, 1.0]);
}

#[test]
fn cycles_duplicates_unknowns_bounds_and_invalid_scalars_are_structured() {
    let (parameters, keys) = parameter_set();
    let cyclic = vec![
        Constraint::Affine(
            AffineConstraint::new(keys[0].clone(), keys[1].clone(), 1.0, 0.0).unwrap(),
        ),
        Constraint::Affine(
            AffineConstraint::new(keys[1].clone(), keys[0].clone(), 1.0, 0.0).unwrap(),
        ),
    ];
    assert!(matches!(
        ConstraintTransform::new(parameters.clone(), cyclic),
        Err(ConstraintError::UnresolvedDependency { .. })
    ));
    assert!(matches!(
        ConstraintTransform::new(
            parameters.clone(),
            vec![
                Constraint::Fixed(FixedConstraint::new(keys[0].clone(), 0.0).unwrap()),
                Constraint::Fixed(FixedConstraint::new(keys[0].clone(), 1.0).unwrap()),
            ],
        ),
        Err(ConstraintError::DuplicateTarget { .. })
    ));
    let transform = ConstraintTransform::new(parameters, Vec::new()).unwrap();
    assert!(matches!(
        transform.unpack(&[-1.0, -1.0, 1.2, 1.0], false),
        Err(ConstraintError::ExpandedValueOutsideBounds { .. })
    ));
    let clipped = transform.unpack(&[-1.0, -1.0, 1.2, 1.0], true).unwrap();
    assert_close(clipped[&keys[0]], 0.0, 0.0);

    assert!(matches!(
        ParameterBounds::new(f64::NAN, 1.0),
        Err(ParameterError::InvalidBounds)
    ));
    assert!(ParameterKey::new(" bad", "owner", "name").is_err());
    assert!(FixedConstraint::new(keys[0].clone(), f64::INFINITY).is_err());
}

#[test]
#[ignore = "requires an installed NumPy Python interpreter"]
fn python_constraint_transform_matches_native_when_configured() {
    let Ok(python) = std::env::var("PHASESMITH_NUMPY_PYTHON") else {
        return;
    };
    let (parameters, keys) = parameter_set();
    let constraint = Constraint::Linear(
        LinearConstraint::new(
            keys[2].clone(),
            vec![
                LinearTerm::new(keys[0].clone(), 0.5).unwrap(),
                LinearTerm::new(keys[1].clone(), 0.2).unwrap(),
            ],
            4.0e-5,
        )
        .unwrap(),
    );
    let transform = ConstraintTransform::new(parameters, vec![constraint]).unwrap();
    let packed = transform.pack().unwrap();
    let unpacked = transform.unpack(&packed, false).unwrap();
    let matrix = transform.derivative_matrix().unwrap();
    let native = packed
        .iter()
        .copied()
        .chain(keys.iter().map(|key| unpacked[key]))
        .chain(matrix.values.iter().copied())
        .collect::<Vec<_>>();

    let script = r#"
from phasesmith.refinement import (
    Bounds, ConstraintTransform, LinearConstraint, ParameterKey, ParameterSet, ParameterSpec
)

keys = (
    ParameterKey("instrument", "bank-1", "u"),
    ParameterKey("instrument", "bank-1", "v"),
    ParameterKey("instrument", "bank-1", "w"),
    ParameterKey("phase", "alpha", "scale"),
)
parameters = ParameterSet([
    ParameterSpec(keys[0], 2.0e-4, "degree^2", Bounds(0.0, 1.0), 1.0e-4),
    ParameterSpec(keys[1], -1.0e-4, "degree^2", Bounds(-1.0, 1.0), 1.0e-4),
    ParameterSpec(keys[2], 1.2e-4, "degree^2", Bounds(0.0, 1.0), 1.0e-4),
    ParameterSpec(keys[3], 1.0, "dimensionless", Bounds(0.0, 10.0), 1.0),
])
transform = ConstraintTransform(parameters, (
    LinearConstraint(keys[2], ((keys[0], 0.5), (keys[1], 0.2)), 4.0e-5),
))
packed = transform.pack()
unpacked = transform.unpack(packed)
for value in (*packed, *(unpacked[key] for key in keys), *transform.derivative_matrix().ravel()):
    print(repr(float(value)))
"#;
    let output = Command::new(python)
        .args(["-c", script])
        .env_remove("PYTHONPATH")
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "Python constraint oracle failed:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let reference = String::from_utf8(output.stdout)
        .unwrap()
        .lines()
        .map(|value| value.parse::<f64>().unwrap())
        .collect::<Vec<_>>();
    assert_eq!(reference.len(), native.len());
    for (actual, expected) in native.iter().zip(reference) {
        assert_close(*actual, expected, 1.0e-18);
    }
}

fn parameter_set() -> (ParameterSet, [ParameterKey; 4]) {
    let keys = [
        key("instrument", "bank-1", "u"),
        key("instrument", "bank-1", "v"),
        key("instrument", "bank-1", "w"),
        key("phase", "alpha", "scale"),
    ];
    let narrow = ParameterBounds::new(-1.0, 1.0).unwrap();
    let parameters = ParameterSet::new(vec![
        ParameterSpec::new(
            keys[0].clone(),
            2.0e-4,
            "degree^2",
            ParameterBounds::new(0.0, 1.0).unwrap(),
            1.0e-4,
            true,
        )
        .unwrap(),
        ParameterSpec::new(keys[1].clone(), -1.0e-4, "degree^2", narrow, 1.0e-4, true).unwrap(),
        ParameterSpec::new(
            keys[2].clone(),
            1.2e-4,
            "degree^2",
            ParameterBounds::new(0.0, 1.0).unwrap(),
            1.0e-4,
            true,
        )
        .unwrap(),
        ParameterSpec::new(
            keys[3].clone(),
            1.0,
            "dimensionless",
            ParameterBounds::new(0.0, 10.0).unwrap(),
            1.0,
            true,
        )
        .unwrap(),
    ])
    .unwrap();
    (parameters, keys)
}

fn key(module: &str, owner: &str, name: &str) -> ParameterKey {
    ParameterKey::new(module, owner, name).unwrap()
}

fn assert_close(actual: f64, expected: f64, absolute_tolerance: f64) {
    assert!((actual - expected).abs() <= absolute_tolerance);
}