pdukit 1.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. pdukit/__init__.py +5 -0
  2. pdukit/__main__.py +7 -0
  3. pdukit/cli.py +96 -0
  4. pdukit/lib/__init__.py +1 -0
  5. pdukit/lib/pdu/__init__.py +10 -0
  6. pdukit/lib/pdu/benchmark.py +362 -0
  7. pdukit/lib/pdu/cog2.py +1251 -0
  8. pdukit/lib/pdu/cog2_checks.py +837 -0
  9. pdukit/lib/pdu/cog2_cli.py +709 -0
  10. pdukit/lib/pdu/cog2_cohesion.py +421 -0
  11. pdukit/lib/pdu/cog2_compare.py +75 -0
  12. pdukit/lib/pdu/cog2_data.py +632 -0
  13. pdukit/lib/pdu/cog2_faces.py +269 -0
  14. pdukit/lib/pdu/cog2_fasttest.py +406 -0
  15. pdukit/lib/pdu/cog2_gate.py +130 -0
  16. pdukit/lib/pdu/cog2_gpu.py +279 -0
  17. pdukit/lib/pdu/cog2_graph.py +154 -0
  18. pdukit/lib/pdu/cog2_ledger.py +162 -0
  19. pdukit/lib/pdu/cog2_outputs.py +193 -0
  20. pdukit/lib/pdu/cog2_pdudef.py +386 -0
  21. pdukit/lib/pdu/cog2_pipeline.py +1404 -0
  22. pdukit/lib/pdu/cog2_resolve.py +247 -0
  23. pdukit/lib/pdu/cog2_run.py +49 -0
  24. pdukit/lib/pdu/cog2_scoring.py +290 -0
  25. pdukit/lib/pdu/cog2_stability.py +561 -0
  26. pdukit/lib/pdu/cog2_summarise.py +99 -0
  27. pdukit/lib/pdu/extract/__init__.py +1 -0
  28. pdukit/lib/pdu/extract/hdf5_layout.py +415 -0
  29. pdukit/lib/pdu/extract/io.py +275 -0
  30. pdukit/lib/pdu/extract/remote_io.py +141 -0
  31. pdukit/lib/pdu/flux_pilot.py +1604 -0
  32. pdukit/lib/pdu/interface.py +3165 -0
  33. pdukit/lib/pdu/null_calibration_cpu.py +611 -0
  34. pdukit/lib/pdu/null_calibration_gpu.py +424 -0
  35. pdukit/lib/pdu/null_calibration_run.py +789 -0
  36. pdukit/lib/pdu/ppf.py +172 -0
  37. pdukit/lib/pdu/schur.py +491 -0
  38. pdukit/lib/pdu/tests/__init__.py +0 -0
  39. pdukit/lib/pdu/tests/test_cli.py +77 -0
  40. pdukit/lib/pdu/tests/test_cog2.py +755 -0
  41. pdukit/lib/pdu/tests/test_cog2_fixes.py +267 -0
  42. pdukit/lib/pdu/tests/test_interface.py +898 -0
  43. pdukit/params/syt_c2a_channels.ppf +122 -0
  44. pdukit/params/syt_c2a_cohesion.ppf +122 -0
  45. pdukit/params/t4l_channels.ppf +122 -0
  46. pdukit/params/t4l_cohesion.ppf +122 -0
  47. pdukit-1.0.0a1.dist-info/METADATA +140 -0
  48. pdukit-1.0.0a1.dist-info/RECORD +52 -0
  49. pdukit-1.0.0a1.dist-info/WHEEL +5 -0
  50. pdukit-1.0.0a1.dist-info/entry_points.txt +5 -0
  51. pdukit-1.0.0a1.dist-info/licenses/LICENSE +675 -0
  52. pdukit-1.0.0a1.dist-info/top_level.txt +1 -0
pdukit/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """pdukit -- COG protein dynamical unit (PDU) detection from MD.
2
+
3
+ lib/pdu/ the COG pipeline (cog2_run.py -> cog2_pipeline.Cog2Run) and helpers
4
+ hpc/ example Slurm scripts and parameter files
5
+ """
pdukit/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m pdukit``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
pdukit/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ """The ``pdu`` command: one entry point with a module per task.
2
+
3
+ pdu run -f R1.xtc R2.xtc -s top.tpr -p t4l.ppf -o out
4
+ pdu bench -p bench.ppf -o out (planted system, known answer)
5
+ pdu check -f ... -s ... -p t4l.ppf (resolve and print; run nothing)
6
+ pdu summarise RESULTS_ROOT
7
+ pdu compare RUN_DIR RUN_DIR ...
8
+ pdu fasttest --out DIR ...
9
+
10
+ Settings live in a parameter file (``-p``, ``key = value``, see
11
+ `pdukit.lib.pdu.ppf`); a command-line option overrides the file.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+
18
+ MODULES = {
19
+ "run": "run the pipeline on MD trajectories",
20
+ "bench": "run the pipeline on a planted system with a known answer",
21
+ "check": "resolve all settings and print them; run nothing",
22
+ "summarise": "one table from many runs",
23
+ "compare": "do several runs find the same units?",
24
+ "fasttest": "the fast test grid of planted designs",
25
+ }
26
+
27
+
28
+ def _version():
29
+ try:
30
+ from importlib.metadata import version
31
+
32
+ return version("pdukit")
33
+ except Exception: # not installed (running from a checkout)
34
+ return "unknown"
35
+
36
+
37
+ def _usage():
38
+ lines = [f"usage: pdu <module> [options] (pdukit {_version()})", ""]
39
+ lines += [f" {name:<10} {text}" for name, text in MODULES.items()]
40
+ lines += ["", "pdu <module> -h shows the options of a module."]
41
+ return "\n".join(lines)
42
+
43
+
44
+ def _run(argv, planted=False):
45
+ from .lib.pdu.cog2_cli import parse_args
46
+ from .lib.pdu.cog2_pipeline import Cog2Run
47
+
48
+ args = parse_args(
49
+ argv + (["--planted"] if planted else []),
50
+ description="pdu run: detect protein dynamical units",
51
+ )
52
+ return Cog2Run(args).run()
53
+
54
+
55
+ def _check(argv):
56
+ from .lib.pdu.cog2_cli import parse_args
57
+
58
+ args = parse_args(
59
+ argv,
60
+ description="pdu check: resolve all settings and print them",
61
+ require_outdir=False,
62
+ )
63
+ sys.stdout.write(args._ppf)
64
+ return 0
65
+
66
+
67
+ def main(argv=None):
68
+ """Dispatch ``pdu <module> ...``; return the exit code."""
69
+ argv = list(sys.argv[1:] if argv is None else argv)
70
+ if not argv or argv[0] in ("-h", "--help", "help"):
71
+ print(_usage())
72
+ return 0
73
+ if argv[0] in ("-V", "--version"):
74
+ print(f"pdukit {_version()}")
75
+ return 0
76
+ module, rest = argv[0], argv[1:]
77
+ if module == "run":
78
+ return _run(rest)
79
+ if module == "bench":
80
+ return _run(rest, planted=True)
81
+ if module == "check":
82
+ return _check(rest)
83
+ if module == "summarise":
84
+ from .lib.pdu.cog2_summarise import main as run_module
85
+ elif module == "compare":
86
+ from .lib.pdu.cog2_compare import main as run_module
87
+ elif module == "fasttest":
88
+ from .lib.pdu.cog2_fasttest import main as run_module
89
+ else:
90
+ print(f"pdu: unknown module {module!r}\n\n{_usage()}", file=sys.stderr)
91
+ return 2
92
+ return run_module(rest) or 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())
pdukit/lib/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Library code of pdukit."""
@@ -0,0 +1,10 @@
1
+ """PDU (Protein Dynamical Unit, the *dynamical* concept).
2
+
3
+ See the top-level README.
4
+
5
+ extract/ per-domain/trajectory -> PDU candidates (the D9 pipeline)
6
+ analyze/ cross-domain -> does the PDU vocabulary stay finite? (K3)
7
+ extractor.py PDUExtractor -- class facade over extract/
8
+ analyzer.py PDUAnalyzer -- class facade over analyze/
9
+ cli.py argv dispatcher used by ../../bin/pdu
10
+ """
@@ -0,0 +1,362 @@
1
+ """COG 2.1 Stage 1: planted benchmark systems with a known answer.
2
+
3
+ A decomposition method can only be trusted on proteins after it has been
4
+ shown to recover units that were put there on purpose. This module builds
5
+ synthetic "proteins" whose dynamical units, couplings and boundary residues
6
+ are known exactly, in the same (frames, residues, features) layout the real
7
+ pipeline uses, so every COG version can be run on them unchanged.
8
+
9
+ CONSTRUCTION (a linear stochastic watch: wheels + gears).
10
+
11
+ * Each planted unit alpha has a private latent state z_alpha of dimension
12
+ `latent_dim`, with dense internal dynamics A_alpha (every residue of the
13
+ unit feels every internal mode -- the unit is cohesive by construction).
14
+ * Units are joined by COUPLINGS (src -> dst, rank r): a rank-r block
15
+ K[dst, src] = g * U V'. Direction matters: a coupling 0 -> 1 lets unit 0
16
+ drive unit 1, not the reverse. This is the gear between two wheels.
17
+ * The whole latent state evolves as a stable VAR(1),
18
+ z_{t+1} = K z_t + noise.
19
+ * Residue i of unit alpha READS OUT its unit's latent state through its own
20
+ random mixing matrix plus private observation noise:
21
+ x_i(t) = W_i z_alpha(t) + e_i(t) (m features per residue)
22
+ * MULTI-COLOUR FACE residues (`face_residues` > 0) sit on the boundary of
23
+ two coupled units and read out BOTH: half their features from one unit,
24
+ half from the other. They belong to both units in the truth.
25
+ * Optional `decoy_pairs` place two units side by side in space with NO
26
+ coupling between them -- a compact blob that is not a unit. A method
27
+ that merges them is rewarding spatial compactness, not dynamics.
28
+
29
+ GEOMETRY. Units are placed as small balls along a chain in 3D; residues get
30
+ coordinates inside their unit's ball, and the contact graph is built from
31
+ distances, so neighbouring units touch at their faces exactly as domains do.
32
+ With `discontinuous=True` each unit's residues are split into two sequence
33
+ segments far apart in the chain, as in lysozyme's discontinuous domains --
34
+ spatially compact, sequence-discontinuous.
35
+
36
+ THE ANSWER (`truth`). Units as residue lists (face residues listed in both),
37
+ couplings with direction and rank, face residues, decoy pairs, and K itself.
38
+
39
+ Nothing here is used by the estimators; it is a test bed.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import numpy as np
45
+
46
+
47
+ def _stable_internal(d, rho_range, rng):
48
+ """Dense internal dynamics with spectral radius drawn from rho_range.
49
+
50
+ A random (non-normal) matrix rescaled, as in test_interface.simulate, so
51
+ internal modes mix every latent coordinate (cohesive unit).
52
+ """
53
+ M = rng.normal(size=(d, d))
54
+ rho = float(rng.uniform(*rho_range))
55
+ return M / np.abs(np.linalg.eigvals(M)).max() * rho
56
+
57
+
58
+ def _unit_vectors(d, r, rng):
59
+ Q, _ = np.linalg.qr(rng.normal(size=(d, r)))
60
+ return Q[:, :r]
61
+
62
+
63
+ def _connect_components(adj, coords):
64
+ """Join disconnected components of the contact graph at their closest
65
+ residue pair, so ball growth can reach every residue.
66
+ """
67
+ N = adj.shape[0]
68
+ while True:
69
+ seen = np.zeros(N, dtype=bool)
70
+ comp = []
71
+ for s in range(N):
72
+ if seen[s]:
73
+ continue
74
+ stack, members = [s], []
75
+ seen[s] = True
76
+ while stack:
77
+ u = stack.pop()
78
+ members.append(u)
79
+ for v in np.where(adj[u])[0]:
80
+ if not seen[v]:
81
+ seen[v] = True
82
+ stack.append(int(v))
83
+ comp.append(members)
84
+ if len(comp) <= 1:
85
+ return adj
86
+ a, b = comp[0], [i for c in comp[1:] for i in c]
87
+ d = np.linalg.norm(
88
+ coords[a][:, None, :] - coords[b][None, :, :], axis=-1
89
+ )
90
+ i, j = np.unravel_index(int(np.argmin(d)), d.shape)
91
+ adj[a[i], b[j]] = adj[b[j], a[i]] = True
92
+
93
+
94
+ def default_couplings(n_units, rank=1):
95
+ """Return a ring of directed rank-`rank` gears.
96
+
97
+ The direction alternates, plus one two-way link, so the benchmark has
98
+ units that only drive, units that only listen, and units that do both.
99
+ """
100
+ cpl = []
101
+ for a in range(n_units - 1):
102
+ if a % 2 == 0:
103
+ cpl.append((a, a + 1, rank))
104
+ else:
105
+ cpl.append((a + 1, a, rank))
106
+ if n_units >= 2:
107
+ cpl.append((1, 0, rank)) # 0 <-> 1 is the two-way link
108
+ return cpl
109
+
110
+
111
+ def planted_system(
112
+ n_units=4,
113
+ unit_size=8,
114
+ m=4,
115
+ latent_dim=5,
116
+ n_frames=6000,
117
+ n_replicas=3,
118
+ couplings=None,
119
+ coupling_gain=0.35,
120
+ internal_rho=(0.80, 0.92),
121
+ noise=0.3,
122
+ obs_noise=0.25,
123
+ face_residues=0,
124
+ decoy_pairs=(),
125
+ discontinuous=False,
126
+ unit_radius=5.0,
127
+ unit_spacing=9.0,
128
+ contact_cutoff=8.0,
129
+ burn_in=500,
130
+ seed=0,
131
+ dtype=np.float32,
132
+ ):
133
+ """Build a planted system.
134
+
135
+ Returns a dict:
136
+
137
+ feats (n_replicas * n_frames, N, m) residue features
138
+ segments [(start, end)] one per replica
139
+ coords (N, 3) residue positions (one static structure)
140
+ adjacency (N, N) bool contact graph
141
+ truth {"units": [...], "couplings": [(src, dst, rank)],
142
+ "face_residues": [...], "decoy_pairs": [...],
143
+ "K": latent transition matrix, "latent_dim": int,
144
+ "unit_of_latent": [...]}
145
+ config every argument, for the record
146
+
147
+ `couplings` is a list of (src, dst, rank); default `default_couplings`.
148
+ `decoy_pairs` is a list of (a, b) unit pairs that must be UNCOUPLED: any
149
+ coupling between them is removed, and they are placed adjacent in space.
150
+ """
151
+ rng = np.random.default_rng(seed)
152
+ n_units, unit_size, m = int(n_units), int(unit_size), int(m)
153
+ d = int(latent_dim)
154
+ if couplings is None:
155
+ couplings = default_couplings(n_units)
156
+ decoy = [tuple(sorted(map(int, p))) for p in decoy_pairs]
157
+ couplings = [
158
+ (int(s), int(t), int(r))
159
+ for s, t, r in couplings
160
+ if tuple(sorted((int(s), int(t)))) not in decoy
161
+ ]
162
+ for s, t, r in couplings:
163
+ if not (0 <= s < n_units and 0 <= t < n_units) or s == t:
164
+ raise ValueError(f"bad coupling {(s, t, r)}")
165
+ if not 1 <= r <= d:
166
+ raise ValueError(f"coupling rank {r} must be in 1..latent_dim")
167
+
168
+ # --- latent dynamics -----------------------------------------------------
169
+ DL = n_units * d
170
+ K = np.zeros((DL, DL))
171
+ for a in range(n_units):
172
+ K[a * d : (a + 1) * d, a * d : (a + 1) * d] = _stable_internal(
173
+ d, internal_rho, rng
174
+ )
175
+ for s, t, r in couplings:
176
+ U, V = _unit_vectors(d, r, rng), _unit_vectors(d, r, rng)
177
+ K[t * d : (t + 1) * d, s * d : (s + 1) * d] += coupling_gain * U @ V.T
178
+ spec = float(np.abs(np.linalg.eigvals(K)).max())
179
+ if spec >= 0.98:
180
+ K *= 0.97 / spec
181
+
182
+ # --- residue layout
183
+ # --------------------------------------------------------
184
+ N = n_units * unit_size
185
+ order = np.arange(N) # noqa: F841
186
+ if discontinuous:
187
+ # unit a gets two half-segments: one in the first half of the chain,
188
+ # one in the second, in the same unit order -- far apart in sequence.
189
+ h1 = unit_size // 2
190
+ h2 = unit_size - h1
191
+ first = [list(range(a * h1, (a + 1) * h1)) for a in range(n_units)]
192
+ off = n_units * h1
193
+ second = [
194
+ list(range(off + a * h2, off + (a + 1) * h2))
195
+ for a in range(n_units)
196
+ ]
197
+ units = [sorted(first[a] + second[a]) for a in range(n_units)]
198
+ else:
199
+ units = [
200
+ list(range(a * unit_size, (a + 1) * unit_size))
201
+ for a in range(n_units)
202
+ ]
203
+ owner = np.empty(N, dtype=int)
204
+ for a, u in enumerate(units):
205
+ owner[u] = a
206
+
207
+ # --- geometry: unit centres on a chain, decoy pairs made adjacent
208
+ # ----------
209
+ centres = np.zeros((n_units, 3))
210
+ for a in range(1, n_units):
211
+ step = rng.normal(size=3)
212
+ step[0] = abs(step[0]) + 1.0
213
+ centres[a] = centres[a - 1] + unit_spacing * step / np.linalg.norm(
214
+ step
215
+ )
216
+ coords = np.zeros((N, 3))
217
+ for i in range(N):
218
+ v = rng.normal(size=3)
219
+ v *= unit_radius * rng.uniform(0.2, 1.0) ** (1 / 3) / np.linalg.norm(v)
220
+ coords[i] = centres[owner[i]] + v
221
+ dist = np.linalg.norm(coords[:, None] - coords[None], axis=-1)
222
+ adj = (dist < contact_cutoff) & ~np.eye(N, dtype=bool)
223
+ for a, b in decoy:
224
+ # guarantee the decoy pair touches, so a compact ball can swallow both
225
+ ia, ib = units[a], units[b]
226
+ dd = dist[np.ix_(ia, ib)]
227
+ for k in np.argsort(dd, axis=None)[:3]:
228
+ i, j = np.unravel_index(int(k), dd.shape)
229
+ adj[ia[i], ib[j]] = adj[ib[j], ia[i]] = True
230
+ adj = _connect_components(adj, coords)
231
+
232
+ # --- multi-colour face residues
233
+ # ---------------------------------------------
234
+ faces = {}
235
+ if face_residues:
236
+ for s, t, _ in couplings:
237
+ # residues of unit t nearest unit s's centre
238
+ cand = [i for i in units[t] if i not in faces]
239
+ dc = np.linalg.norm(coords[cand] - centres[s], axis=1)
240
+ for i in [cand[k] for k in np.argsort(dc)[: int(face_residues)]]:
241
+ faces[int(i)] = (int(t), int(s)) # (home unit, second unit)
242
+
243
+ # --- simulate
244
+ # ---------------------------------------------------------------
245
+ W = rng.normal(size=(N, m, d)) / np.sqrt(d)
246
+ W2 = rng.normal(size=(N, m, d)) / np.sqrt(d)
247
+ feats = np.empty((n_replicas * n_frames, N, m), dtype=dtype)
248
+ segments = []
249
+ for rep in range(int(n_replicas)):
250
+ z = np.zeros(DL)
251
+ traj = np.empty((n_frames, DL))
252
+ for t in range(-int(burn_in), n_frames):
253
+ z = K @ z + rng.normal(scale=noise, size=DL)
254
+ if t >= 0:
255
+ traj[t] = z
256
+ out = np.empty((n_frames, N, m))
257
+ for i in range(N):
258
+ a = owner[i]
259
+ za = traj[:, a * d : (a + 1) * d]
260
+ x = za @ W[i].T
261
+ if i in faces:
262
+ b = faces[i][1]
263
+ zb = traj[:, b * d : (b + 1) * d]
264
+ half = m // 2
265
+ x[:, half:] = (zb @ W2[i].T)[:, half:]
266
+ out[:, i, :] = x + rng.normal(scale=obs_noise, size=(n_frames, m))
267
+ a0 = rep * n_frames
268
+ feats[a0 : a0 + n_frames] = out.astype(dtype)
269
+ segments.append((a0, a0 + n_frames))
270
+
271
+ truth_units = [
272
+ sorted(set(u) | {i for i, (h, b) in faces.items() if b == a})
273
+ for a, u in enumerate(units)
274
+ ]
275
+ truth = {
276
+ "units": truth_units,
277
+ "home_units": [sorted(u) for u in units],
278
+ "couplings": couplings,
279
+ "face_residues": sorted(faces),
280
+ "face_owners": {int(i): list(v) for i, v in faces.items()},
281
+ "decoy_pairs": [list(p) for p in decoy],
282
+ "K": K,
283
+ "latent_dim": d,
284
+ "unit_of_latent": [a for a in range(n_units) for _ in range(d)],
285
+ }
286
+ config = dict(
287
+ n_units=n_units,
288
+ unit_size=unit_size,
289
+ m=m,
290
+ latent_dim=d,
291
+ n_frames=n_frames,
292
+ n_replicas=n_replicas,
293
+ couplings=couplings,
294
+ coupling_gain=coupling_gain,
295
+ internal_rho=tuple(internal_rho),
296
+ noise=noise,
297
+ obs_noise=obs_noise,
298
+ face_residues=face_residues,
299
+ decoy_pairs=[list(p) for p in decoy],
300
+ discontinuous=discontinuous,
301
+ seed=seed,
302
+ spectral_radius=float(np.abs(np.linalg.eigvals(K)).max()),
303
+ )
304
+ return {
305
+ "feats": feats,
306
+ "segments": segments,
307
+ "coords": coords,
308
+ "adjacency": adj,
309
+ "truth": truth,
310
+ "config": config,
311
+ }
312
+
313
+
314
+ def match_units(found, truth_units, *, threshold=0.67):
315
+ """Compare a decomposition with the planted answer.
316
+
317
+ For every TRUE unit, the best Jaccard overlap with any found unit
318
+ (recall side); for every FOUND unit, the best overlap with any true unit
319
+ (precision side). A true unit counts as recovered when its best overlap
320
+ reaches `threshold`. Also reports how many found units straddle two true
321
+ units (each overlapping by >= 25 % of the found unit) -- the merge error.
322
+ The default threshold is 0.67: at 0.5 a merged 20-residue unit counted
323
+ as recovering both of the 10-residue units inside it.
324
+ """
325
+ F = [set(map(int, u)) for u in found]
326
+ T = [set(map(int, u)) for u in truth_units]
327
+
328
+ def jac(a, b):
329
+ return len(a & b) / max(len(a | b), 1)
330
+
331
+ rec = [max((jac(t, f) for f in F), default=0.0) for t in T]
332
+ prec = [max((jac(f, t) for t in T), default=0.0) for f in F]
333
+ merged = 0
334
+ for f in F:
335
+ big = [t for t in T if len(f & t) >= 0.25 * max(len(f), 1)]
336
+ merged += int(len(big) >= 2)
337
+ return {
338
+ "recall_jaccard": [round(x, 4) for x in rec],
339
+ "precision_jaccard": [round(x, 4) for x in prec],
340
+ "mean_recall": float(np.mean(rec)) if rec else 0.0,
341
+ "mean_precision": float(np.mean(prec)) if prec else 0.0,
342
+ "n_recovered": int(sum(x >= threshold for x in rec)),
343
+ "n_true": len(T),
344
+ "n_found": len(F),
345
+ "n_merged": int(merged),
346
+ "threshold": float(threshold),
347
+ }
348
+
349
+
350
+ def face_recovery(boundary_residues, truth):
351
+ """Fraction of planted multi-colour face residues that the method
352
+ reported as boundary (shared) residues, and how many reported boundary
353
+ residues are not faces.
354
+ """
355
+ faces = set(truth.get("face_residues", []))
356
+ got = set(map(int, boundary_residues))
357
+ return {
358
+ "n_faces": len(faces),
359
+ "found": len(faces & got),
360
+ "fraction": (len(faces & got) / len(faces)) if faces else float("nan"),
361
+ "extra_boundary": len(got - faces),
362
+ }