opdiv 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.
opdiv/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Molecular portfolio selection and diversity evaluation."""
2
+
3
+ from ._core import (
4
+ MetricUndefinedError,
5
+ Portfolio,
6
+ gpdiv,
7
+ opdiv,
8
+ select,
9
+ )
10
+ from ._similarity import conflicts_from_similarity, tanimoto_similarity
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "MetricUndefinedError",
15
+ "Portfolio",
16
+ "conflicts_from_similarity",
17
+ "gpdiv",
18
+ "opdiv",
19
+ "select",
20
+ "tanimoto_similarity",
21
+ ]
opdiv/_core.py ADDED
@@ -0,0 +1,273 @@
1
+ """Fixed-cardinality portfolio objectives, independent of molecular representation."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ import numpy as np
7
+ from numpy.typing import ArrayLike, NDArray
8
+ from ortools.sat.python import cp_model
9
+
10
+ from ._similarity import conflicts_from_similarity
11
+
12
+ _OBJECTIVE_SCALE = 1_000_000_000
13
+ _OPTIMALITY_TOLERANCE = 1e-8
14
+
15
+ Status = Literal["optimal", "feasible", "infeasible", "incomplete", "unknown"]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Portfolio:
20
+ """Selection and its mean-utility bounds at the requested capacity.
21
+
22
+ ``indices`` refer to input rows, ordered by descending score, then input
23
+ index. ``value`` is None unless exactly k compatible candidates were found.
24
+ ``upper_bound`` bounds OPDiv, including integer-coefficient rounding error.
25
+ Optimality is numerical: the certified mean gap is at most 1e-8 times the
26
+ centered score scale, subject to floating-point arithmetic.
27
+ """
28
+
29
+ indices: tuple[int, ...]
30
+ k: int
31
+ value: float | None
32
+ upper_bound: float | None
33
+ status: Status
34
+
35
+ @property
36
+ def is_complete(self) -> bool:
37
+ return len(self.indices) == self.k
38
+
39
+ @property
40
+ def gap(self) -> float | None:
41
+ """Absolute gap in mean-utility units, or None without a full portfolio."""
42
+ if self.value is None or self.upper_bound is None:
43
+ return None
44
+ return max(0.0, self.upper_bound - self.value)
45
+
46
+
47
+ class MetricUndefinedError(ValueError):
48
+ """No full portfolio or no proven optimum; inspect ``result`` for details."""
49
+
50
+ def __init__(self, metric: str, result: Portfolio):
51
+ self.result = result
52
+ super().__init__(
53
+ f"{metric} is unavailable (status={result.status}, "
54
+ f"selected={len(result.indices)}/{result.k}); use select() to inspect bounds"
55
+ )
56
+
57
+
58
+ def _inputs(scores: ArrayLike, k: int) -> NDArray[np.float64]:
59
+ q = np.asarray(scores, dtype=float)
60
+ if q.ndim != 1 or not np.isfinite(q).all():
61
+ raise ValueError("scores must be a one-dimensional array of finite utilities")
62
+ if isinstance(k, bool) or not isinstance(k, (int, np.integer)) or k < 1:
63
+ raise ValueError("k must be a positive integer")
64
+ return q
65
+
66
+
67
+ def _graph(
68
+ m: int,
69
+ conflicts: ArrayLike | None,
70
+ similarities: ArrayLike | None,
71
+ max_similarity: float | None,
72
+ ) -> NDArray[np.bool_]:
73
+ if similarities is not None:
74
+ if conflicts is not None or max_similarity is None:
75
+ raise ValueError("Supply similarities with max_similarity, or conflicts, not both")
76
+ a = conflicts_from_similarity(similarities, max_similarity)
77
+ else:
78
+ if max_similarity is not None:
79
+ raise ValueError("max_similarity requires similarities")
80
+ if conflicts is None:
81
+ return np.zeros((m, m), dtype=bool)
82
+ raw = np.asarray(conflicts)
83
+ if not np.isin(raw, [0, 1]).all():
84
+ raise ValueError("conflicts must contain only booleans or 0/1")
85
+ a = raw.astype(bool)
86
+ if a.shape != (m, m) or not np.array_equal(a, a.T):
87
+ raise ValueError("The pairwise matrix must be symmetric and match the number of scores")
88
+ if np.diag(a).any():
89
+ raise ValueError("conflicts must have a false diagonal")
90
+ return a
91
+
92
+
93
+ def _result(q, picks, k, upper, status) -> Portfolio:
94
+ indices = tuple(sorted((int(i) for i in picks), key=lambda i: (-q[i], i)))
95
+ # Divide first to avoid overflowing the sum of finite scores.
96
+ value = float(np.sum(q[list(indices)] / k)) if len(indices) == k else None
97
+ return Portfolio(indices, int(k), value, upper, status)
98
+
99
+
100
+ def _greedy(q, a, k) -> list[int]:
101
+ blocked = np.zeros(len(q), dtype=bool)
102
+ picks = []
103
+ for i in np.argsort(-q, kind="stable"):
104
+ if not blocked[i]:
105
+ picks.append(int(i))
106
+ blocked |= a[i]
107
+ blocked[i] = True
108
+ if len(picks) == k:
109
+ break
110
+ return picks
111
+
112
+
113
+ def select(
114
+ scores: ArrayLike,
115
+ k: int,
116
+ *,
117
+ similarities: ArrayLike | None = None,
118
+ max_similarity: float | None = None,
119
+ conflicts: ArrayLike | None = None,
120
+ method: Literal["optimal", "greedy"] = "optimal",
121
+ time_limit: float | None = None,
122
+ ) -> Portfolio:
123
+ """Select exactly k candidates maximizing mean score under pairwise exclusion.
124
+
125
+ Larger scores are better: negate docking energies before calling. Supply
126
+ either a similarity matrix and ceiling (equality allowed), a boolean conflict
127
+ matrix, or neither for ordinary top-k. Candidates must already be eligible
128
+ and distinct; matrix inputs cannot establish molecular identity.
129
+
130
+ ``optimal`` uses OR-Tools CP-SAT optimization without a time limit
131
+ by default. ``time_limit`` limits solver seconds, excluding graph preparation.
132
+ Integer rounding is included in the returned upper bound. Optimality is
133
+ certified within 1e-8 times the centered score scale. A stopped search
134
+ returns a feasible lower bound if available. ``greedy`` scans by score with
135
+ input-order tie breaking;
136
+ underfilling does not establish infeasibility. Equally optimal portfolios may
137
+ differ across solver versions; returned indices are always score-sorted.
138
+ """
139
+ q = _inputs(scores, k)
140
+ a = _graph(len(q), conflicts, similarities, max_similarity)
141
+ if method not in ("optimal", "greedy"):
142
+ raise ValueError("method must be 'optimal' or 'greedy'")
143
+ if time_limit is not None:
144
+ if not np.isscalar(time_limit) or not np.isfinite(time_limit) or time_limit <= 0:
145
+ raise ValueError("time_limit must be positive and finite")
146
+ if method != "optimal":
147
+ raise ValueError("time_limit applies only to optimal selection")
148
+ if k > len(q):
149
+ return _result(q, [], k, None, "infeasible")
150
+ top = np.argsort(-q, kind="stable")[:k]
151
+ upper = float(np.sum(q[top] / k))
152
+ if not a[np.ix_(top, top)].any():
153
+ return _result(q, top, k, upper, "optimal")
154
+ picks = _greedy(q, a, k)
155
+ if method == "greedy":
156
+ return _result(q, picks, k, upper, "feasible" if len(picks) == k else "incomplete")
157
+
158
+ # Center/rescale before integer conversion so offsets and units do not
159
+ # erase score differences. At fixed k this preserves the exact objective.
160
+ center = float(np.max(q) / 2 + np.min(q) / 2)
161
+ centered = q - center
162
+ scale = float(np.max(np.abs(centered))) or 1.0
163
+ normalized = centered / scale
164
+ weights = np.rint(normalized * _OBJECTIVE_SCALE).astype(np.int64)
165
+ # For ANY size-k set, the largest k coefficient errors bound mean error.
166
+ errors = np.abs(normalized - weights / _OBJECTIVE_SCALE)
167
+ rounding_error = float(np.sum(np.sort(errors)[-k:] / k))
168
+
169
+ model = cp_model.CpModel()
170
+ x = [model.new_bool_var(f"x{i}") for i in range(len(q))]
171
+ model.add(sum(x) == k)
172
+ ii, jj = np.where(np.triu(a, 1))
173
+ for i, j in zip(ii, jj, strict=True):
174
+ model.add_at_most_one(x[i], x[j])
175
+ objective = sum(int(w) * var for w, var in zip(weights, x, strict=True))
176
+ model.maximize(objective)
177
+ if len(picks) == k:
178
+ chosen = set(picks)
179
+ model.add(objective >= sum(int(weights[i]) for i in picks))
180
+ for i, var in enumerate(x):
181
+ model.add_hint(var, int(i in chosen))
182
+ else:
183
+ picks = []
184
+
185
+ solver = cp_model.CpSolver()
186
+ solver.parameters.num_search_workers = 1
187
+ solver.parameters.random_seed = 20260914
188
+ solver.parameters.relative_gap_limit = 0
189
+ solver.parameters.absolute_gap_limit = 0
190
+ if time_limit is not None:
191
+ solver.parameters.max_time_in_seconds = float(time_limit)
192
+ code = solver.solve(model)
193
+ if code == cp_model.INFEASIBLE:
194
+ if picks:
195
+ raise RuntimeError("Solver reported infeasibility despite a feasible greedy portfolio")
196
+ return _result(q, [], k, None, "infeasible")
197
+ if code not in (cp_model.OPTIMAL, cp_model.FEASIBLE, cp_model.UNKNOWN):
198
+ raise RuntimeError(f"Portfolio solver failed: {solver.solution_info()}")
199
+
200
+ status = "feasible" if picks else "unknown"
201
+ if code in (cp_model.OPTIMAL, cp_model.FEASIBLE):
202
+ candidate = [i for i, var in enumerate(x) if solver.value(var)]
203
+ if len(candidate) != k or a[np.ix_(candidate, candidate)].any():
204
+ raise RuntimeError("Solver returned an invalid portfolio")
205
+ if not picks or np.sum(normalized[candidate]) > np.sum(normalized[picks]):
206
+ picks = candidate
207
+ # UNKNOWN can expose an uninitialized solver bound; retain top-k then.
208
+ normalized_upper = (
209
+ solver.best_objective_bound / (_OBJECTIVE_SCALE * k) + rounding_error
210
+ )
211
+ # Small outward allowance for arithmetic in scaling and bound conversion.
212
+ normalized_upper += 16 * np.finfo(float).eps
213
+ with np.errstate(over="ignore"):
214
+ upper = min(upper, float(normalized_upper * scale + center))
215
+ value = float(np.sum(q[picks] / k))
216
+ upper = max(upper, value)
217
+ normalized_gap = normalized_upper - float(np.sum(normalized[picks] / k))
218
+ status = (
219
+ "optimal"
220
+ if code == cp_model.OPTIMAL and normalized_gap <= _OPTIMALITY_TOLERANCE
221
+ else "feasible"
222
+ )
223
+ return _result(q, picks, k, upper, status)
224
+
225
+
226
+ def opdiv(
227
+ scores: ArrayLike,
228
+ k: int,
229
+ *,
230
+ similarities: ArrayLike | None = None,
231
+ max_similarity: float | None = None,
232
+ conflicts: ArrayLike | None = None,
233
+ time_limit: float | None = None,
234
+ ) -> float:
235
+ """Optimal Portfolio Diversity: the best feasible size-k mean utility.
236
+
237
+ Raise MetricUndefinedError for infeasibility or an unproven optimum.
238
+ Optimality uses the numerical tolerance documented by select().
239
+ The exception's result retains any feasible portfolio and its bounds.
240
+ """
241
+ result = select(
242
+ scores,
243
+ k,
244
+ similarities=similarities,
245
+ max_similarity=max_similarity,
246
+ conflicts=conflicts,
247
+ time_limit=time_limit,
248
+ )
249
+ if result.status != "optimal" or result.value is None:
250
+ raise MetricUndefinedError("OPDiv", result)
251
+ return result.value
252
+
253
+
254
+ def gpdiv(
255
+ scores: ArrayLike,
256
+ k: int,
257
+ *,
258
+ similarities: ArrayLike | None = None,
259
+ max_similarity: float | None = None,
260
+ conflicts: ArrayLike | None = None,
261
+ ) -> float:
262
+ """Greedy Portfolio Diversity; raise MetricUndefinedError if underfilled."""
263
+ result = select(
264
+ scores,
265
+ k,
266
+ similarities=similarities,
267
+ max_similarity=max_similarity,
268
+ conflicts=conflicts,
269
+ method="greedy",
270
+ )
271
+ if result.value is None:
272
+ raise MetricUndefinedError("GPDiv", result)
273
+ return result.value
opdiv/_similarity.py ADDED
@@ -0,0 +1,65 @@
1
+ """Pairwise conflict graphs and optional molecular fingerprints."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import numpy as np
6
+ from numpy.typing import ArrayLike, NDArray
7
+
8
+
9
+ def conflicts_from_similarity(similarities: ArrayLike, max_similarity: float) -> NDArray[np.bool_]:
10
+ """Build a graph with an edge exactly when s(i, j) > max_similarity.
11
+
12
+ Equality is compatible. Require a finite, exactly symmetric square matrix;
13
+ the diagonal is ignored. Similarities need not be in [0, 1]. No implicit
14
+ symmetrization or threshold tolerance is applied.
15
+ """
16
+ s = np.asarray(similarities, dtype=float)
17
+ if s.ndim != 2 or s.shape[0] != s.shape[1]:
18
+ raise ValueError("similarities must be a square matrix")
19
+ if not np.isfinite(s).all() or not np.array_equal(s, s.T):
20
+ raise ValueError("similarities must be finite and symmetric")
21
+ if not np.isscalar(max_similarity) or not np.isfinite(max_similarity):
22
+ raise ValueError("max_similarity must be finite")
23
+ graph = s > max_similarity
24
+ np.fill_diagonal(graph, False)
25
+ return graph
26
+
27
+
28
+ def tanimoto_similarity(
29
+ smiles: Sequence[str], *, radius: int = 2, n_bits: int = 2048
30
+ ) -> NDArray[np.float64]:
31
+ """Morgan bit-fingerprint Tanimoto matrix; requires ``opdiv[chem]``.
32
+
33
+ Uses no chirality, matching the paper, and preserves input order. Invalid, empty, or duplicate
34
+ canonical isomeric SMILES raise ValueError. No salt/tautomer normalization
35
+ is performed; establish your molecular identity policy before calling.
36
+ """
37
+ try:
38
+ from rdkit import Chem, DataStructs
39
+ from rdkit.Chem import rdFingerprintGenerator
40
+ except ImportError as exc:
41
+ raise ImportError("Install molecular support with: pip install 'opdiv[chem]'") from exc
42
+
43
+ if isinstance(smiles, str):
44
+ raise ValueError("smiles must be a sequence of SMILES strings")
45
+ for name, value, minimum in [("radius", radius, 0), ("n_bits", n_bits, 1)]:
46
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer)) or value < minimum:
47
+ raise ValueError(f"{name} must be an integer >= {minimum}")
48
+ generator = rdFingerprintGenerator.GetMorganGenerator(
49
+ radius=int(radius), fpSize=int(n_bits), includeChirality=False
50
+ )
51
+ fingerprints, seen = [], set()
52
+ for i, text in enumerate(smiles):
53
+ mol = Chem.MolFromSmiles(text) if isinstance(text, str) and text.strip() else None
54
+ if mol is None or mol.GetNumAtoms() == 0:
55
+ raise ValueError(f"Invalid or empty SMILES at index {i}: {text!r}")
56
+ identity = Chem.MolToSmiles(mol, isomericSmiles=True)
57
+ if identity in seen:
58
+ raise ValueError(f"Duplicate molecule at index {i}: {text!r}")
59
+ seen.add(identity)
60
+ fingerprints.append(generator.GetFingerprint(mol))
61
+ matrix = np.eye(len(fingerprints), dtype=float)
62
+ for i in range(1, len(fingerprints)):
63
+ matrix[i, :i] = DataStructs.BulkTanimotoSimilarity(fingerprints[i], fingerprints[:i])
64
+ matrix[:i, i] = matrix[i, :i]
65
+ return matrix
opdiv/py.typed ADDED
File without changes
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: opdiv
3
+ Version: 0.1.0
4
+ Summary: Molecular portfolio selection and diversity evaluation
5
+ Author: Miroslav Lzicar
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/mireklzicar/opdiv
8
+ Project-URL: Issues, https://github.com/mireklzicar/opdiv/issues
9
+ Keywords: cheminformatics,diversity,portfolio,molecule-selection
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=1.23
19
+ Requires-Dist: ortools<10,>=9.15
20
+ Provides-Extra: chem
21
+ Requires-Dist: rdkit>=2023.9; extra == "chem"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: build>=1.2; extra == "dev"
25
+ Requires-Dist: twine>=5; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # OPDiv
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/opdiv.svg)](https://pypi.org/project/opdiv/)
32
+
33
+ Molecular portfolio selection and diversity evaluation.
34
+
35
+ Choose exactly **k** candidates with the highest mean score while enforcing a
36
+ pairwise similarity ceiling. Use the resulting mean to evaluate a screening or
37
+ generative method's candidate archive.
38
+
39
+ Implements **Optimal Portfolio Diversity (OPDiv)**, its score-ordered greedy
40
+ approximation **GPDiv**, from
41
+ *Measuring Diversity of Top-K Molecules as an Optimal Portfolio Selection Problem*
42
+ by Miroslav Lžičař (preprint draft, September 2026).
43
+
44
+ ## Install
45
+
46
+ Python 3.10 or newer:
47
+
48
+ ```bash
49
+ pip install opdiv
50
+ ```
51
+
52
+ For development, from this repository:
53
+
54
+ ```bash
55
+ pip install -e '.[chem,dev]'
56
+ ```
57
+
58
+ The core requires NumPy and OR-Tools. The optional `chem` extra adds RDKit for
59
+ building Morgan/Tanimoto similarities from SMILES.
60
+
61
+ ## Select a portfolio
62
+
63
+ ```python
64
+ from opdiv import select, opdiv, gpdiv
65
+
66
+ # The paper's counterexample: A conflicts with B and C; B and C are compatible.
67
+ scores = [16.0, 12.0, 11.0, 6.0] # A, B, C, D; larger is better
68
+ conflicts = [
69
+ [0, 1, 1, 0],
70
+ [1, 0, 0, 0],
71
+ [1, 0, 0, 0],
72
+ [0, 0, 0, 0],
73
+ ]
74
+
75
+ result = select(scores, k=2, conflicts=conflicts)
76
+ print(result.indices) # (1, 2): B and C
77
+ print(result.value) # 11.5
78
+ print(result.status) # optimal
79
+
80
+ # Metric-only calls also perform selection internally.
81
+ print(opdiv(scores, k=2, conflicts=conflicts)) # 11.5
82
+ print(gpdiv(scores, k=2, conflicts=conflicts)) # 11.0: A and D
83
+
84
+ greedy = select(scores, k=2, conflicts=conflicts, method="greedy")
85
+ ```
86
+
87
+ `indices` are zero-based input positions, sorted by descending score, then input
88
+ position. Index a Python list with `[items[i] for i in result.indices]`, or a
89
+ DataFrame with `df.iloc[list(result.indices)]`. Scores are **utilities**: for
90
+ lower-is-better docking energies, pass their negatives.
91
+
92
+ ## From molecules or custom similarities
93
+
94
+ Install RDKit support with `pip install 'opdiv[chem]'`.
95
+
96
+ ```python
97
+ from opdiv import select, tanimoto_similarity
98
+
99
+ smiles = ["CCO", "CCCO", "c1ccccc1", "CC(=O)O"]
100
+ scores = [0.9, 0.8, 0.7, 0.6]
101
+ similarities = tanimoto_similarity(smiles) # Morgan radius 2, 2048 bits, no chirality
102
+
103
+ result = select(scores, k=2, similarities=similarities, max_similarity=0.4)
104
+ selected_smiles = [smiles[i] for i in result.indices]
105
+ ```
106
+
107
+ Any finite symmetric similarity matrix works, including shape or electrostatic
108
+ comparisons. A pair conflicts exactly when **similarity > max_similarity**;
109
+ equality is allowed, and no numerical epsilon is added. The similarity diagonal
110
+ is ignored. Asymmetric matrices must be explicitly symmetrized before use.
111
+
112
+ Alternatively, supply a symmetric boolean/0–1 `conflicts` matrix with a false
113
+ diagonal. For distances, construct conflicts with `distances < min_distance`
114
+ and set the diagonal to false. Use `<=` if equality should conflict. With neither
115
+ matrix, `select(scores, k)` returns ordinary top-k.
116
+
117
+ Filter ineligible candidates and resolve molecular identity **before selection**.
118
+ The SMILES helper rejects invalid/empty molecules and duplicate canonical
119
+ isomeric SMILES; it does not normalize salts or tautomers. Matrix-based calls
120
+ assume each row is a distinct eligible candidate.
121
+
122
+ ## Feasibility and bounds
123
+
124
+ ```python
125
+ result = select(scores, k=2, similarities=similarities,
126
+ max_similarity=0.4, time_limit=10.0)
127
+ print(result.status, result.value, result.upper_bound, result.gap)
128
+ ```
129
+
130
+ | Status | Meaning |
131
+ |---|---|
132
+ | `optimal` | Optimum certified within the numerical tolerance below; inspect `gap`. |
133
+ | `feasible` | Full portfolio found; its mean is a lower bound on OPDiv. |
134
+ | `incomplete` | Greedy stopped short; another full portfolio may exist. |
135
+ | `infeasible` | The requested capacity is proven infeasible. |
136
+ | `unknown` | Search stopped without finding a full portfolio or proving infeasibility. |
137
+
138
+ `value` is the mean of a full selection, or `None` when underfilled. A partial
139
+ greedy result retains its indices, but has no GPDiv value at the requested k.
140
+ `upper_bound` bounds the optimum; `gap` is the absolute difference from `value`.
141
+ Neither repeating candidates nor relaxing the diversity threshold fills a result.
142
+
143
+ `opdiv(...)` raises `MetricUndefinedError` unless optimality is established;
144
+ `gpdiv(...)` raises it when its metric is undefined. The exception
145
+ has a `.result` containing the selection and available bounds. Use `select(...)`
146
+ when you want to inspect a time-limited result rather than require a scalar metric.
147
+
148
+ Optimal selection uses [OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver),
149
+ with binary variables, exactly k selections, and one exclusion constraint per
150
+ conflict edge. A complete greedy portfolio supplies a solver hint and an objective
151
+ lower bound. One search worker and a fixed seed make runs reproducible within a
152
+ solver version.
153
+
154
+ Scores are centered and rescaled before conversion to integer coefficients at
155
+ precision 1e-9 in normalized units. The largest k coefficient-rounding errors
156
+ are included in the returned upper bound. `optimal` means CP-SAT proved the
157
+ integer optimum and the original-score mean gap is at most 1e-8 times the
158
+ centered score scale (the largest absolute centered score, or 1 for constant
159
+ scores), subject to floating-point arithmetic. A small nonzero `gap` can remain
160
+ due to rounding; extremely close portfolios can be indistinguishable at this
161
+ precision. Returned `value` always uses the original scores.
162
+
163
+ There is no default time limit; `time_limit` limits the solver only. On a stopped
164
+ search, a complete greedy portfolio is retained if it beats the solver's candidate.
165
+ Greedy ties follow input order. Equally optimal selections can differ between
166
+ solver versions. Pairwise matrices require O(m²) memory; difficult conflict graphs
167
+ can be expensive to optimize.
168
+
169
+ This package uses the paper's CP-SAT backend and OPDiv/GPDiv definitions. It keeps
170
+ a direct full-graph model; the experiment runner's clique compression, score-prefix
171
+ relaxations, and benchmark pipeline are not included. Its normalized integer
172
+ scaling also differs from the experiment runner's fixed score multiplier.
173
+ For reproduction, use identical eligible candidates, row ordering, scores, and
174
+ conflict graphs: experiment-specific threshold tolerances must be encoded in
175
+ `conflicts` explicitly. The molecular helper matches the paper's non-chiral
176
+ fingerprint settings; compute fingerprints from the original archived graphs
177
+ when reproducing the archive experiments.
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ python -m pytest
183
+ ruff check .
184
+ python -m build
185
+ python -m twine check dist/*
186
+ ```
187
+
188
+ Tests compare optimal results and rounding-aware bounds with exhaustive enumeration,
189
+ exercise the paper's greedy failure, and cover threshold boundaries, negative
190
+ utilities, time-limit outcomes, input validation, and optional molecular support.
191
+
192
+ The source repository is [mireklzicar/opdiv](https://github.com/mireklzicar/opdiv);
193
+ [Deep-MedChem/opdiv](https://github.com/Deep-MedChem/opdiv) is its organization fork.
194
+
195
+ ## Attribution
196
+
197
+ The portfolio metrics and their molecular evaluation application are introduced
198
+ in the paper above. The underlying graph optimization and score-ordered greedy
199
+ algorithms are established methods; this package does not claim their invention.
200
+ See the paper for the scientific formulation and prior work. MIT licensed.
@@ -0,0 +1,9 @@
1
+ opdiv/__init__.py,sha256=OYvAocEm9jUDqfAa2unHK_ZN1OGQKWgriNkuXNhHAOc,415
2
+ opdiv/_core.py,sha256=FIswYtHSjQOrdggJDeXTU5hNV9Z-8DGYDzoZo87W8Q8,10618
3
+ opdiv/_similarity.py,sha256=0zbCToNTxjB8rBjPrXciBOZKaqdaANT3JyXOqCUXdSQ,3042
4
+ opdiv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ opdiv-0.1.0.dist-info/licenses/LICENSE,sha256=7fdoLNKBWOccTfhQpUWuKLk7SmWgY5J2854_FAAYg98,1072
6
+ opdiv-0.1.0.dist-info/METADATA,sha256=Zc01Ss4tWz9KsJ-zH2UxWFMqs7aydzY6PwtiUWzd9TM,8334
7
+ opdiv-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ opdiv-0.1.0.dist-info/top_level.txt,sha256=9RGXvX84CgQni5tP3y2fxHNWawdJ08OllQVYHEaGpOI,6
9
+ opdiv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miroslav Lzicar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ opdiv