Skip to content

Commit 313a2ca

Browse files
Daniel Hinderinkclaude
andcommitted
feat(fold): tensor-network protein folding backend with TEBD solver
Add fold module to arvak-proj: MPS-based protein folding simulation using the same adaptive bond dimension machinery (sin(C/2)) that powers the quantum circuit projector. Pipeline: PDB → ContactMap → ANM → Commensurability → Hamiltonian → TEBD Modules: - pdb: Minimal Cα parser (no external PDB library) - contact: Native contact map, crossing profile, relative contact order - anm: Anisotropic Network Model (Hessian eigendecomposition via nalgebra) - commensurability: sin(C/2) scoring of contacts → adaptive χ allocation - hamiltonian: Go-model with backbone, local (Ramachandran), and LR terms - mpo: Finite State Automaton MPO construction with pruning - dmrg: Two-site DMRG with adaptive bond dimension (baseline solver) - tebd: Imaginary-time TEBD with SWAP networks (primary solver) - trajectory: Folding pathway analysis, ⟨r⟩ intermediate detection Performance (1FME BBA, 28 residues, d=3, χ∈[4,16], release): TEBD: 11.8s — 86× faster than DMRG baseline 20 unit tests + 17 integration tests on real PDB data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 41f3945 commit 313a2ca

14 files changed

Lines changed: 3326 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/arvak-proj/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ tracing = { workspace = true }
1717
serde = { workspace = true }
1818
rayon = "1.10"
1919
faer = "0.24"
20+
nalgebra = "0.33"

crates/arvak-proj/src/fold/anm.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use nalgebra::{DMatrix, SymmetricEigen};
2+
3+
pub struct ANMResult {
4+
pub eigenvalues: Vec<f64>,
5+
pub frequencies: Vec<f64>,
6+
pub eigenvectors: Vec<Vec<f64>>,
7+
pub n_modes: usize,
8+
pub n_residues: usize,
9+
}
10+
11+
impl ANMResult {
12+
pub fn compute(
13+
chain: &super::pdb::ProteinChain,
14+
cutoff: f64,
15+
gamma: f64,
16+
n_modes: Option<usize>,
17+
) -> Self {
18+
let n = chain.len();
19+
let dim = 3 * n;
20+
21+
// Build Hessian matrix (3N x 3N)
22+
let mut hess = DMatrix::<f64>::zeros(dim, dim);
23+
24+
for i in 0..n {
25+
for j in i + 1..n {
26+
let d = chain.distance(i, j);
27+
if d > cutoff || d < 1e-10 {
28+
continue;
29+
}
30+
31+
let ci = &chain.residues[i].coords;
32+
let cj = &chain.residues[j].coords;
33+
let d2 = d * d;
34+
35+
// Off-diagonal 3x3 block: -gamma * (r_ij (x) r_ij) / |r_ij|^2
36+
for a in 0..3 {
37+
for b in 0..3 {
38+
let val = -gamma * (ci[a] - cj[a]) * (ci[b] - cj[b]) / d2;
39+
hess[(3 * i + a, 3 * j + b)] = val;
40+
hess[(3 * j + b, 3 * i + a)] = val;
41+
// Diagonal blocks: subtract off-diagonal contributions
42+
hess[(3 * i + a, 3 * i + b)] -= val;
43+
hess[(3 * j + a, 3 * j + b)] -= val;
44+
}
45+
}
46+
}
47+
}
48+
49+
// Eigendecomposition
50+
let eigen = SymmetricEigen::new(hess);
51+
let mut eig_pairs: Vec<(f64, Vec<f64>)> = eigen
52+
.eigenvalues
53+
.iter()
54+
.enumerate()
55+
.map(|(idx, &val)| {
56+
let vec: Vec<f64> = eigen.eigenvectors.column(idx).iter().copied().collect();
57+
(val, vec)
58+
})
59+
.collect();
60+
61+
// Sort by eigenvalue ascending
62+
eig_pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
63+
64+
// Skip first 6 trivial modes (translation + rotation)
65+
let non_trivial: Vec<_> = eig_pairs
66+
.into_iter()
67+
.filter(|(val, _)| *val > 1e-6)
68+
.collect();
69+
70+
let keep = n_modes.unwrap_or(non_trivial.len()).min(non_trivial.len());
71+
72+
let eigenvalues: Vec<f64> = non_trivial[..keep].iter().map(|(v, _)| *v).collect();
73+
let frequencies: Vec<f64> = eigenvalues.iter().map(|v| v.sqrt()).collect();
74+
let eigenvectors: Vec<Vec<f64>> =
75+
non_trivial[..keep].iter().map(|(_, v)| v.clone()).collect();
76+
77+
ANMResult {
78+
eigenvalues,
79+
frequencies,
80+
eigenvectors,
81+
n_modes: keep,
82+
n_residues: n,
83+
}
84+
}
85+
86+
pub fn residue_participation(&self, residue: usize) -> Vec<(usize, f64)> {
87+
let mut participations: Vec<(usize, f64)> = self
88+
.eigenvectors
89+
.iter()
90+
.enumerate()
91+
.map(|(mode_idx, evec)| {
92+
let base = 3 * residue;
93+
let p = evec[base].powi(2) + evec[base + 1].powi(2) + evec[base + 2].powi(2);
94+
(mode_idx, p)
95+
})
96+
.collect();
97+
participations.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
98+
participations
99+
}
100+
101+
pub fn local_frequencies(&self, residue: usize, n: usize) -> Vec<f64> {
102+
self.residue_participation(residue)
103+
.into_iter()
104+
.take(n)
105+
.map(|(idx, _)| self.frequencies[idx])
106+
.collect()
107+
}
108+
109+
pub fn level_spacing_ratio(&self) -> f64 {
110+
if self.eigenvalues.len() < 3 {
111+
return 0.0;
112+
}
113+
let spacings: Vec<f64> = self
114+
.eigenvalues
115+
.windows(2)
116+
.map(|w| (w[1] - w[0]).abs())
117+
.collect();
118+
if spacings.len() < 2 {
119+
return 0.0;
120+
}
121+
let ratios: Vec<f64> = spacings
122+
.windows(2)
123+
.map(|w| {
124+
let (a, b) = (w[0], w[1]);
125+
let (min, max) = if a < b { (a, b) } else { (b, a) };
126+
if max < 1e-15 { 0.0 } else { min / max }
127+
})
128+
.collect();
129+
ratios.iter().sum::<f64>() / ratios.len() as f64
130+
}
131+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
pub struct CommensurabilityResult {
2+
pub contact_scores: Vec<f64>,
3+
pub bond_budget: Vec<f64>,
4+
pub adaptive_chi: Vec<usize>,
5+
}
6+
7+
impl CommensurabilityResult {
8+
pub fn compute(
9+
anm: &super::anm::ANMResult,
10+
contacts: &super::contact::ContactMap,
11+
n_local_modes: usize,
12+
) -> Self {
13+
let mut contact_scores = Vec::with_capacity(contacts.contacts.len());
14+
15+
for contact in &contacts.contacts {
16+
let freqs_i = anm.local_frequencies(contact.i, n_local_modes);
17+
let freqs_j = anm.local_frequencies(contact.j, n_local_modes);
18+
let parts_i = anm.residue_participation(contact.i);
19+
let parts_j = anm.residue_participation(contact.j);
20+
21+
let mut score = 0.0;
22+
let mut norm = 0.0;
23+
24+
for (a, &fi) in freqs_i.iter().enumerate() {
25+
let pi = parts_i.get(a).map_or(0.0, |(_, p)| *p);
26+
for (b, &fj) in freqs_j.iter().enumerate() {
27+
let pj = parts_j.get(b).map_or(0.0, |(_, p)| *p);
28+
let w = pi * pj;
29+
if fi > 1e-10 && fj > 1e-10 {
30+
let ratio = fi / fj;
31+
let frac = ratio - ratio.floor();
32+
let sin_val = (std::f64::consts::PI * frac).sin().abs();
33+
score += w * (1.0 - sin_val);
34+
}
35+
norm += w;
36+
}
37+
}
38+
39+
let c = if norm > 1e-15 { score / norm } else { 0.0 };
40+
contact_scores.push(c.clamp(0.0, 1.0));
41+
}
42+
43+
// Bond budget: sum of commensurability scores crossing each bond
44+
let n = contacts.n_residues;
45+
let mut bond_budget = vec![0.0; n.saturating_sub(1)];
46+
for (idx, contact) in contacts.contacts.iter().enumerate() {
47+
for k in contact.i..contact.j.min(bond_budget.len()) {
48+
bond_budget[k] += contact_scores[idx];
49+
}
50+
}
51+
52+
CommensurabilityResult {
53+
contact_scores,
54+
bond_budget,
55+
adaptive_chi: Vec::new(), // filled by to_adaptive_chi
56+
}
57+
}
58+
59+
pub fn to_adaptive_chi(&mut self, chi_min: usize, chi_max: usize) -> Vec<usize> {
60+
if self.bond_budget.is_empty() {
61+
return Vec::new();
62+
}
63+
64+
let max_budget = self.bond_budget.iter().copied().fold(0.0_f64, f64::max);
65+
if max_budget < 1e-15 {
66+
self.adaptive_chi = vec![chi_min; self.bond_budget.len()];
67+
return self.adaptive_chi.clone();
68+
}
69+
70+
self.adaptive_chi = self
71+
.bond_budget
72+
.iter()
73+
.map(|&b| {
74+
let frac = (b / max_budget).sqrt();
75+
let chi = chi_min + ((chi_max - chi_min) as f64 * frac) as usize;
76+
chi.clamp(chi_min, chi_max)
77+
})
78+
.collect();
79+
80+
self.adaptive_chi.clone()
81+
}
82+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
pub struct Contact {
2+
pub i: usize,
3+
pub j: usize,
4+
pub distance: f64,
5+
pub seq_sep: usize,
6+
}
7+
8+
pub struct ContactMap {
9+
pub contacts: Vec<Contact>,
10+
pub n_residues: usize,
11+
}
12+
13+
impl ContactMap {
14+
pub fn from_chain(chain: &super::pdb::ProteinChain, cutoff: f64, min_sep: usize) -> Self {
15+
let n = chain.len();
16+
let mut contacts = Vec::new();
17+
for i in 0..n {
18+
for j in i + min_sep..n {
19+
let d = chain.distance(i, j);
20+
if d <= cutoff {
21+
contacts.push(Contact {
22+
i,
23+
j,
24+
distance: d,
25+
seq_sep: j - i,
26+
});
27+
}
28+
}
29+
}
30+
ContactMap {
31+
contacts,
32+
n_residues: n,
33+
}
34+
}
35+
36+
pub fn contacts_crossing_bond(&self, k: usize) -> Vec<&Contact> {
37+
self.contacts
38+
.iter()
39+
.filter(|c| c.i <= k && c.j > k)
40+
.collect()
41+
}
42+
43+
pub fn crossing_profile(&self) -> Vec<usize> {
44+
let mut profile = vec![0usize; self.n_residues.saturating_sub(1)];
45+
for c in &self.contacts {
46+
for k in c.i..c.j.min(profile.len()) {
47+
profile[k] += 1;
48+
}
49+
}
50+
profile
51+
}
52+
53+
pub fn relative_contact_order(&self) -> f64 {
54+
if self.contacts.is_empty() || self.n_residues == 0 {
55+
return 0.0;
56+
}
57+
let total_sep: f64 = self.contacts.iter().map(|c| c.seq_sep as f64).sum();
58+
total_sep / (self.contacts.len() as f64 * self.n_residues as f64)
59+
}
60+
}

0 commit comments

Comments
 (0)