Maintainer triage (June 29, 2026)
- Status: Verified bug against the current
master source in this fork.
- Severity: High.
- Area labels: area:crystallizers.
- Tackle batch: P4 - high-impact numerical/model correctness.
The source review confirmed that the cited code path and failure mode are still present. The original audit details are preserved below for reproduction notes and suggested fixes.
Original audit details
Part of the model-correctness audit — see #17.
Severity: high | Category: sign-convention | Location: PharmaPy/Crystallizers.py lines 1401-1410
What's wrong
In jac_states, the concentration RHS is dcomp_i/dt = -transf/vol * (kron_jtg_i - conc_i/rho_l) (material_balances line 1513, with transf == tr from line 1401). Differentiating w.r.t. conc_j gives two terms: dfconc_dconc_ij = -(1/vol)[ (d tr/d conc_j)(kron_i - conc_i/rho_l) - trdelta_ij/rho_l ]. The second (diagonal) term must enter the bracket with a MINUS sign because d(kron_i - conc_i/rho_l)/d conc_j = -delta_ij/rho_l. The code instead builds second_conc = tr/rho_lnp.eye(...) and adds it: dfconc_dconc = -1/vol_liq*(dtr_dconc_tgfirst_conc + second_conc). This makes the diagonal contribution -tr/(volrho_l) instead of the correct +tr/(vol*rho_l), i.e. the sign of the entire self-derivative term is inverted for every species (including the target).
Why it's incorrect
Hand differentiation of dcomp_i = -tr/vol*(kron_i - conc_i/rho_l): the chain-rule on the bracket yields -tr/vol * (-delta_ij/rho_l) = +tr/(vol*rho_l)delta_ij. The code produces -tr/(volrho_l)*delta_ij. The off-diagonal/outer-product (first_conc) term is correct and the analogous dfconc_dvol (line 1421) and dfconc_dmu2 (line 1378) are correct, which confirms the leading -1/vol sign convention; only second_conc has the wrong relative sign. This directly corrupts the species-concentration block of the sensitivity RHS (rhs_sensitivity line 697 uses jac_states_vals) and any Newton/Jacobian-based integration that consumes the analytical jac_states.
Suggested regression test
Finite-difference cross-check: set method='moments' on a BatchCryst, call jac_states(time, states, params, return_only=False) and compare against numerical_jac_central(lambda st: unit_model(time, st, params), states). The num_distr:-1 x num_distr:-1 sub-block diagonal entries will have opposite sign to the FD reference; fixing requires second_conc = -tr/rho_l*np.eye(len(w_conc)) (or subtracting it in the bracket).
Verification: 2/2 adversarial reviewers confirmed (refuted: 0).
Maintainer triage (June 29, 2026)
mastersource in this fork.The source review confirmed that the cited code path and failure mode are still present. The original audit details are preserved below for reproduction notes and suggested fixes.
Original audit details
Part of the model-correctness audit — see #17.
Severity: high | Category: sign-convention | Location:
PharmaPy/Crystallizers.pylines 1401-1410What's wrong
In jac_states, the concentration RHS is dcomp_i/dt = -transf/vol * (kron_jtg_i - conc_i/rho_l) (material_balances line 1513, with transf == tr from line 1401). Differentiating w.r.t. conc_j gives two terms: dfconc_dconc_ij = -(1/vol)[ (d tr/d conc_j)(kron_i - conc_i/rho_l) - trdelta_ij/rho_l ]. The second (diagonal) term must enter the bracket with a MINUS sign because d(kron_i - conc_i/rho_l)/d conc_j = -delta_ij/rho_l. The code instead builds second_conc = tr/rho_lnp.eye(...) and adds it: dfconc_dconc = -1/vol_liq*(dtr_dconc_tgfirst_conc + second_conc). This makes the diagonal contribution -tr/(volrho_l) instead of the correct +tr/(vol*rho_l), i.e. the sign of the entire self-derivative term is inverted for every species (including the target).
Why it's incorrect
Hand differentiation of dcomp_i = -tr/vol*(kron_i - conc_i/rho_l): the chain-rule on the bracket yields -tr/vol * (-delta_ij/rho_l) = +tr/(vol*rho_l)delta_ij. The code produces -tr/(volrho_l)*delta_ij. The off-diagonal/outer-product (first_conc) term is correct and the analogous dfconc_dvol (line 1421) and dfconc_dmu2 (line 1378) are correct, which confirms the leading -1/vol sign convention; only second_conc has the wrong relative sign. This directly corrupts the species-concentration block of the sensitivity RHS (rhs_sensitivity line 697 uses jac_states_vals) and any Newton/Jacobian-based integration that consumes the analytical jac_states.
Suggested regression test
Finite-difference cross-check: set method='moments' on a BatchCryst, call jac_states(time, states, params, return_only=False) and compare against numerical_jac_central(lambda st: unit_model(time, st, params), states). The num_distr:-1 x num_distr:-1 sub-block diagonal entries will have opposite sign to the FD reference; fixing requires second_conc = -tr/rho_l*np.eye(len(w_conc)) (or subtracting it in the bracket).
Verification: 2/2 adversarial reviewers confirmed (refuted: 0).