polyscript-pypi 0.1.0__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 (57) hide show
  1. MCP/server.py +770 -0
  2. polyscript/__init__.py +12 -0
  3. polyscript/brics/BRICSMod.py +1164 -0
  4. polyscript/brics/BRICS_utils.py +189 -0
  5. polyscript/brics/__init__.py +48 -0
  6. polyscript/classifier/__init__.py +270 -0
  7. polyscript/classifier/class_reference.py +951 -0
  8. polyscript/depolymerizer/__init__.py +576 -0
  9. polyscript/depolymerizer/mpi.py +390 -0
  10. polyscript/depolymerizer/reaction_reference.py +447 -0
  11. polyscript/polymerizer/__init__.py +22 -0
  12. polyscript/polymerizer/core.py +648 -0
  13. polyscript/polymerizer/mpi.py +860 -0
  14. polyscript/polymerizer/mpi_df.py +378 -0
  15. polyscript/polymerizer/utils.py +795 -0
  16. polyscript/utils/__init__.py +80 -0
  17. polyscript/utils/adapters.py +357 -0
  18. polyscript/utils/canon_analyze.py +193 -0
  19. polyscript/utils/executor.py +66 -0
  20. polyscript/utils/logger.py +331 -0
  21. polyscript/utils/mpi/__init__.py +33 -0
  22. polyscript/utils/mpi/checkpoint.py +263 -0
  23. polyscript/utils/mpi/logger.py +138 -0
  24. polyscript/utils/mpi/progress.py +134 -0
  25. polyscript/utils/mpi/protocol.py +113 -0
  26. polyscript/utils/mpi/resource.py +94 -0
  27. polyscript/utils/mpi/splitter.py +42 -0
  28. polyscript/utils/mpi_executor.py +167 -0
  29. polyscript/utils/mpi_validator.py +940 -0
  30. polyscript/utils/parsers.py +177 -0
  31. polyscript/utils/validators.py +201 -0
  32. polyscript_pypi-0.1.0.dist-info/METADATA +82 -0
  33. polyscript_pypi-0.1.0.dist-info/RECORD +57 -0
  34. polyscript_pypi-0.1.0.dist-info/WHEEL +5 -0
  35. polyscript_pypi-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. polyscript_pypi-0.1.0.dist-info/top_level.txt +4 -0
  37. runners/adapt_dataframe.py +105 -0
  38. runners/brics_decompose.py +28 -0
  39. runners/canonicalization.py +46 -0
  40. runners/filter_candidates.py +54 -0
  41. runners/mpi_depolymerize.py +51 -0
  42. runners/mpi_runner.py +66 -0
  43. runners/mpi_runner_df.py +49 -0
  44. runners/mpi_validator.py +45 -0
  45. tests/conftest.py +10 -0
  46. tests/sample_gen.py +10 -0
  47. tests/test_brics.py +275 -0
  48. tests/test_classifier.py +201 -0
  49. tests/test_mpi_polymerize.py +170 -0
  50. tests/test_polymerize.py +206 -0
  51. tests/utils/test_adaptors.py +182 -0
  52. tests/utils/test_canonicalization.py +62 -0
  53. tests/utils/test_executor.py +101 -0
  54. tests/utils/test_logger.py +426 -0
  55. tests/utils/test_mpi_validator.py +159 -0
  56. tests/utils/test_parsers.py +150 -0
  57. tests/utils/test_validator.py +105 -0
@@ -0,0 +1,390 @@
1
+ """
2
+ MPI-parallel depolymerizer.
3
+
4
+ ``MpiDepolymerizer`` is a distributed batch-depolymerization runner that
5
+ composes from ``polyscript.utils.mpi`` components (MpiLogger, MpiProtocol,
6
+ WorkSplitter, ProgressTracker) to scatter sequences across MPI ranks,
7
+ run ``PolyScriptDepolymerizer``, and gather per-class statistics.
8
+
9
+ Usage (launch with mpirun)::
10
+
11
+ from polyscript.depolymerizer.mpi import MpiDepolymerizer
12
+
13
+ runner = MpiDepolymerizer(
14
+ input_path="benchmark.parquet",
15
+ mode="validate",
16
+ )
17
+ stats = runner.run()
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Dict, List, Optional
25
+
26
+ import pandas as pd
27
+
28
+ from polyscript.depolymerizer import PolyScriptDepolymerizer
29
+ from polyscript.utils.mpi import (
30
+ MpiLogger,
31
+ MpiProtocol,
32
+ ProgressTracker,
33
+ WorkSplitter,
34
+ )
35
+
36
+
37
+ class MpiDepolymerizer:
38
+ """Distributed depolymerization runner.
39
+
40
+ Components:
41
+ * ``self.logger`` — MpiLogger (direct-stderr + file)
42
+ * ``self.mpi`` — MpiProtocol (scatter/gather)
43
+ * ``self.splitter`` — WorkSplitter (round-robin)
44
+ * ``self.progress`` — ProgressTracker (elapsed, rate, ETA)
45
+
46
+ Parameters
47
+ ----------
48
+ input_path : str
49
+ Path to a parquet or CSV file containing polymer sequences.
50
+ mode : str
51
+ ``"validate"`` (PolyScript sequences) or ``"convert"`` (raw PSMILES).
52
+ smiles_col : str, optional
53
+ Column name for SMILES strings (auto-detected when ``None``).
54
+ output_dir : str
55
+ Directory for results and logs.
56
+ log_filepath : str
57
+ Log filename written inside *output_dir*.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ input_path: str,
63
+ *,
64
+ mode: str = "validate",
65
+ smiles_col: Optional[str] = None,
66
+ output_dir: str = ".",
67
+ log_filepath: str = "depolymerizer.log",
68
+ log_interval: int = 100,
69
+ ):
70
+ # ── MPI init ──────────────────────────────────────────────────
71
+ from mpi4py import MPI as _MPI # noqa: N813
72
+
73
+ comm = _MPI.COMM_WORLD
74
+ self._rank = comm.Get_rank()
75
+ self._size = comm.Get_size()
76
+ self._is_master = self._rank == 0
77
+
78
+ if self._size < 2:
79
+ raise RuntimeError(
80
+ "MpiDepolymerizer needs at least 2 MPI ranks. "
81
+ f"Got {self._size}. Launch with mpirun."
82
+ )
83
+
84
+ # ── Components ────────────────────────────────────────────────
85
+ self.mpi = MpiProtocol(comm, self._rank, self._size, self._is_master)
86
+ self.splitter = WorkSplitter()
87
+ self._log_interval = max(log_interval, 1)
88
+
89
+ self._input_path = str(Path(input_path).resolve())
90
+ self._mode = mode
91
+ self._smiles_col = smiles_col
92
+ self._output_dir = Path(output_dir).resolve()
93
+
94
+ if self._is_master:
95
+ self.logger = MpiLogger(
96
+ name="depolymerizer",
97
+ log_filepath=str(self._output_dir / log_filepath),
98
+ progress_file=str(self._output_dir / "progress.txt"),
99
+ use_rich=False,
100
+ use_direct_stderr=True,
101
+ )
102
+ else:
103
+ import logging
104
+ self.logger = logging.getLogger(__name__ + f".rank{self._rank}")
105
+ self.logger.setLevel(logging.WARNING)
106
+
107
+ self.mpi.barrier()
108
+
109
+ # ------------------------------------------------------------------
110
+ # Lifecycle
111
+ # ------------------------------------------------------------------
112
+
113
+ def close(self):
114
+ """Flush and close the logger if present."""
115
+ if hasattr(self, "logger"):
116
+ try:
117
+ self.logger.flush()
118
+ self.logger.close()
119
+ except AttributeError:
120
+ pass
121
+
122
+ def __enter__(self):
123
+ """Support context-manager usage (``with MpiDepolymerizer(...) as``)."""
124
+ return self
125
+
126
+ def __exit__(self, *args):
127
+ """Clean up on context-manager exit."""
128
+ self.close()
129
+
130
+ # ------------------------------------------------------------------
131
+ # Master: read input & scatter
132
+ # ------------------------------------------------------------------
133
+
134
+ def _load_sequences(self) -> tuple:
135
+ """Read the input file and return (sequences, indices, classes)."""
136
+ ip = Path(self._input_path)
137
+ if ip.suffix == ".parquet":
138
+ df = pd.read_parquet(ip)
139
+ else:
140
+ df = pd.read_csv(ip)
141
+
142
+ if self._mode == "convert":
143
+ col = self._smiles_col
144
+ if col is None:
145
+ for candidate in ["smiles", "canonical_smiles", "psmiles", "polym"]:
146
+ if candidate in df.columns:
147
+ col = candidate
148
+ break
149
+ if col is None:
150
+ raise KeyError("No SMILES column found. Pass --smiles_col.")
151
+ sequences = df[col].dropna().tolist()
152
+ classes = ["unknown"] * len(sequences)
153
+ else:
154
+ sequences = df["polym"].tolist()
155
+ classes = (
156
+ df["polymer_class"].tolist()
157
+ if "polymer_class" in df.columns
158
+ else ["unknown"] * len(sequences)
159
+ )
160
+
161
+ indices = list(range(len(sequences)))
162
+ return sequences, indices, classes
163
+
164
+ # ------------------------------------------------------------------
165
+ # Worker: process one sequence
166
+ # ------------------------------------------------------------------
167
+
168
+ @staticmethod
169
+ def _process_sequence(
170
+ dp: PolyScriptDepolymerizer,
171
+ seq: str,
172
+ mode: str,
173
+ ) -> list:
174
+ """Run depolymerization on a single sequence. Returns pathway list."""
175
+ if mode == "convert":
176
+ return dp.convert_psmiles(seq)
177
+ return dp.validate(seq)
178
+
179
+ # ------------------------------------------------------------------
180
+ # run
181
+ # ------------------------------------------------------------------
182
+
183
+ def run(self) -> Dict:
184
+ """Run the distributed depolymerization job.
185
+
186
+ On the master rank (0), loads sequences, scatters them to
187
+ workers, processes its own chunk, gathers results, and returns
188
+ summary statistics. Worker ranks process their chunk and return
189
+ an empty dict.
190
+
191
+ Returns:
192
+ dict: Summary stats with keys ``total``, ``success``,
193
+ ``failed``, ``pathways`` (master only; workers return ``{}``).
194
+ """
195
+ if self._is_master:
196
+ return self._run_master()
197
+ else:
198
+ self._run_worker()
199
+ return {}
200
+
201
+ # ------------------------------------------------------------------
202
+ # Master orchestration
203
+ # ------------------------------------------------------------------
204
+
205
+ def _run_master(self) -> Dict:
206
+ """Master orchestration: load, scatter, process, gather, summarize.
207
+
208
+ Loads the input file, distributes sequences across MPI ranks
209
+ via round-robin, processes the master's own chunk, gathers
210
+ results from all ranks, logs per-class statistics, and saves
211
+ results/failures to parquet files.
212
+
213
+ Returns:
214
+ dict: Summary stats (``total``, ``success``, ``failed``,
215
+ ``pathways``).
216
+ """
217
+ comm = self.mpi.comm
218
+ t_start = time.monotonic()
219
+
220
+ # ── Load & scatter ────────────────────────────────────────────
221
+ sequences, indices, classes = self._load_sequences()
222
+ total = len(sequences)
223
+
224
+ seq_chunks = self.splitter.round_robin(sequences, self._size)
225
+ idx_chunks = self.splitter.round_robin(indices, self._size)
226
+ cls_chunks = self.splitter.round_robin(classes, self._size)
227
+
228
+ self.logger.info(
229
+ "Loaded %d sequences, distributing across %d ranks", total, self._size,
230
+ )
231
+
232
+ comm.scatter(seq_chunks, root=0)
233
+ comm.scatter(idx_chunks, root=0)
234
+ comm.scatter(cls_chunks, root=0)
235
+
236
+ # ── Process master's own chunk ────────────────────────────────
237
+ my_seqs = seq_chunks[0]
238
+ my_idxs = idx_chunks[0]
239
+ my_clss = cls_chunks[0]
240
+
241
+ results, fails, ok, bad, per_class = self._process_chunk(
242
+ my_seqs, my_idxs, my_clss, t_start,
243
+ )
244
+
245
+ # ── Gather from workers ───────────────────────────────────────
246
+ all_results = comm.gather(results, root=0)
247
+ all_fails = comm.gather(fails, root=0)
248
+ all_per_class = comm.gather(per_class, root=0)
249
+
250
+ # ── Summarise ─────────────────────────────────────────────────
251
+ aggregated: dict[str, int] = {}
252
+ for pc_dict in all_per_class:
253
+ for pclass, count in pc_dict.items():
254
+ aggregated[pclass] = aggregated.get(pclass, 0) + count
255
+
256
+ # Include master's own results
257
+ flat_results = [r for chunk in all_results for r in chunk]
258
+ flat_fails = [f for chunk in all_fails for f in chunk]
259
+
260
+ seq_ok = {r.seq_index for r in flat_results}
261
+ seq_fail_idx = {f["seq_index"] for f in flat_fails}
262
+ n_total = len(seq_ok) + len(seq_fail_idx)
263
+
264
+ self.logger.info("=" * 60)
265
+ self.logger.info("DEPOLYMERIZATION SUMMARY")
266
+ self.logger.info("=" * 60)
267
+ self.logger.info(" MPI ranks: %d", self._size)
268
+ self.logger.info(" Total sequences: %d", n_total)
269
+ self.logger.info(" Success: %d", len(seq_ok))
270
+ self.logger.info(" Failed: %d", len(seq_fail_idx))
271
+ self.logger.info(" Total pathways: %d", len(flat_results))
272
+
273
+ for pclass in sorted(aggregated):
274
+ pclass_fails = sum(
275
+ 1 for f in flat_fails if f["polymer_class"] == pclass
276
+ )
277
+ total_class = aggregated[pclass] + pclass_fails
278
+ pct = 100 * aggregated[pclass] / max(1, total_class)
279
+ self.logger.info(
280
+ " %-20s %4d/%d (%.0f%%)",
281
+ pclass, aggregated[pclass], total_class, pct,
282
+ )
283
+
284
+ # ── Save results ──────────────────────────────────────────────
285
+ if flat_results:
286
+ out_df = pd.DataFrame([
287
+ {
288
+ "seq_index": r.seq_index,
289
+ "input_monomers": r.input_monomers,
290
+ "output_monomers": r.monomers,
291
+ "exact_match": r.is_exact_match,
292
+ "reaction": r.reaction,
293
+ "polymer": r.polymer,
294
+ "polymer_pattern": r.polymer_pattern,
295
+ }
296
+ for r in flat_results
297
+ ])
298
+ results_path = self._output_dir / "depolymerize_results.parquet"
299
+ out_df.to_parquet(results_path)
300
+ self.logger.info(" Saved %d pathways to %s", len(out_df), results_path)
301
+
302
+ if flat_fails:
303
+ fail_df = pd.DataFrame(flat_fails)
304
+ fail_path = self._output_dir / "depolymerize_fails.parquet"
305
+ fail_df.to_parquet(fail_path)
306
+ self.logger.info(" Saved %d fail cases to %s", len(fail_df), fail_path)
307
+
308
+ self.mpi.shutdown_workers()
309
+ self.logger.flush()
310
+ return {
311
+ "total": n_total,
312
+ "success": len(seq_ok),
313
+ "failed": len(seq_fail_idx),
314
+ "pathways": len(flat_results),
315
+ }
316
+
317
+ # ------------------------------------------------------------------
318
+ # Shared: process a chunk of sequences
319
+ # ------------------------------------------------------------------
320
+
321
+ def _process_chunk(
322
+ self,
323
+ sequences: list,
324
+ indices: list,
325
+ classes: list,
326
+ t_start: float,
327
+ ) -> tuple:
328
+ """Run depolymerization on a local chunk. Returns results tuple."""
329
+ dp = PolyScriptDepolymerizer()
330
+ results = []
331
+ fails = []
332
+ ok = 0
333
+ bad = 0
334
+ per_class: dict[str, int] = {}
335
+ n = len(sequences)
336
+
337
+ progress = ProgressTracker(total=n, log_interval=self._log_interval)
338
+
339
+ for i, (seq, gidx, pclass) in enumerate(zip(sequences, indices, classes)):
340
+ seq_results = self._process_sequence(dp, seq, self._mode)
341
+
342
+ if seq_results:
343
+ ok += 1
344
+ per_class[pclass] = per_class.get(pclass, 0) + 1
345
+ for r in seq_results:
346
+ r.seq_index = gidx
347
+ results.append(r)
348
+ else:
349
+ bad += 1
350
+ fails.append(
351
+ {"seq_index": gidx, "sequence": seq, "polymer_class": pclass}
352
+ )
353
+
354
+ progress.tick(ok=bool(seq_results))
355
+
356
+ if progress.should_log() or i == n - 1:
357
+ self.logger.info(
358
+ "rank %2d: %s",
359
+ self._rank, progress.format_line(),
360
+ )
361
+ progress.mark_logged()
362
+
363
+ self.logger.info(
364
+ "rank %2d: DONE ok=%d fail=%d out of %d (%.1fs)",
365
+ self._rank, ok, bad, n, progress.elapsed,
366
+ )
367
+ return results, fails, ok, bad, per_class
368
+
369
+ # ------------------------------------------------------------------
370
+ # Worker loop
371
+ # ------------------------------------------------------------------
372
+
373
+ def _run_worker(self):
374
+ """Worker loop: receive chunk from master, process, send back results."""
375
+ comm = self.mpi.comm
376
+
377
+ # Receive scattered data from master
378
+ my_sequences = comm.scatter(None, root=0)
379
+ my_indices = comm.scatter(None, root=0)
380
+ my_classes = comm.scatter(None, root=0)
381
+
382
+ t_start = time.monotonic()
383
+ results, fails, ok, bad, per_class = self._process_chunk(
384
+ my_sequences, my_indices, my_classes, t_start,
385
+ )
386
+
387
+ # Send results back
388
+ comm.gather(results, root=0)
389
+ comm.gather(fails, root=0)
390
+ comm.gather(per_class, root=0)