Skip to content

Commit 7b59eb7

Browse files
committed
[feat.dirac.Ω.D9+] make Motif an auditable discovery engine
Drug-design decisions now traverse one durable Command, Method, Job, Artifact, and governance spine, with explicit uncertainty and claim boundaries instead of synthetic scientific certainty.
1 parent ec77b9f commit 7b59eb7

63 files changed

Lines changed: 10653 additions & 1698 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- 029 · runtime parameter refusals are expected Job outcomes.
2+
--
3+
-- JSON Schema catches structural mistakes before a Job exists, but scientific
4+
-- handlers must also validate values that require RDKit or another engine. A
5+
-- malformed SMILES can therefore be discovered after the durable row is open.
6+
-- Mapping that typed INVALID_PARAMETERS refusal to INTERNAL made caller input
7+
-- appear as an operational incident and polluted app.v_attention.
8+
9+
BEGIN;
10+
11+
ALTER TYPE app.job_error
12+
ADD VALUE IF NOT EXISTS 'INVALID_PARAMETERS' BEFORE 'INTERNAL';
13+
14+
COMMENT ON TYPE app.job_error IS
15+
'Terminal Job error vocabulary. INVALID_PARAMETERS includes runtime scientific validation after a Job has opened.';
16+
17+
INSERT INTO meta.migration (filename, sha256, content_sha256, hash_source)
18+
VALUES ('029_job_invalid_parameters.sql', '\x6f2e4c72450ce1a6db7b071dd8d8ddc455ee26fccad0aaa54882e74738b0f597'::bytea,
19+
'\x6f2e4c72450ce1a6db7b071dd8d8ddc455ee26fccad0aaa54882e74738b0f597'::bytea, 'content')
20+
ON CONFLICT (filename) DO NOTHING;
21+
22+
COMMIT;

‎backend/dirac_app/handlers.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,36 @@ def campaign_bayesian_rank(input: dict, ctx) -> dict:
163163
actor=ctx.actor, command_id=ctx.command_id)
164164

165165

166+
def structure_conformers(input: dict, ctx) -> dict:
167+
return ctx.kernel.submit(
168+
'structure.motif.conformers', input, request_id=ctx.request_id,
169+
actor=ctx.actor, command_id=ctx.command_id)
170+
171+
172+
def structure_vina(input: dict, ctx) -> dict:
173+
return ctx.kernel.submit(
174+
'structure.motif.vina', input, request_id=ctx.request_id,
175+
actor=ctx.actor, command_id=ctx.command_id)
176+
177+
178+
def physics_openmm_md(input: dict, ctx) -> dict:
179+
return ctx.kernel.submit(
180+
'physics.motif.openmm_md', input, request_id=ctx.request_id,
181+
actor=ctx.actor, command_id=ctx.command_id)
182+
183+
184+
def physics_rbfe_network(input: dict, ctx) -> dict:
185+
return ctx.kernel.submit(
186+
'physics.motif.rbfe_network', input, request_id=ctx.request_id,
187+
actor=ctx.actor, command_id=ctx.command_id)
188+
189+
190+
def physics_rbfe_aggregate(input: dict, ctx) -> dict:
191+
return ctx.kernel.submit(
192+
'physics.motif.rbfe_aggregate', input, request_id=ctx.request_id,
193+
actor=ctx.actor, command_id=ctx.command_id)
194+
195+
166196
def proposal_generate(input: dict, ctx) -> dict:
167197
strategy = input["strategy"]
168198
method_id = ("design.motif.local_edits" if strategy == "local_edit"

‎backend/envelope.py‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,32 @@
6464
def _job_error_enum_from_migrations() -> tuple[frozenset[str], pathlib.Path]:
6565
"""Parse `CREATE TYPE app.job_error AS ENUM (...)` out of the migration
6666
CHAIN (git, not a live database — this module must import with Postgres
67-
down). A later migration that redefines the type would win; today there
68-
is exactly one `CREATE TYPE`, in 007, and none since."""
69-
pattern = re.compile(
67+
down). Later ``ALTER TYPE ... ADD VALUE`` migrations extend the set in
68+
order, exactly as PostgreSQL does."""
69+
create_pattern = re.compile(
7070
r'CREATE\s+TYPE\s+app\.job_error\s+AS\s+ENUM\s*\(([^)]*)\)',
7171
re.IGNORECASE | re.DOTALL)
72+
add_pattern = re.compile(
73+
r'ALTER\s+TYPE\s+app\.job_error\s+ADD\s+VALUE(?:\s+IF\s+NOT\s+EXISTS)?'
74+
r"\s+'([^']+)'", re.IGNORECASE)
7275
found: tuple[frozenset[str], pathlib.Path] | None = None
76+
values: set[str] = set()
77+
source: pathlib.Path | None = None
7378
for path in sorted(_MIGRATIONS_DIR.glob('*.sql')):
74-
m = pattern.search(path.read_text(encoding='utf-8'))
79+
text = path.read_text(encoding='utf-8')
80+
m = create_pattern.search(text)
7581
if m:
76-
found = (frozenset(re.findall(r"'([^']*)'", m.group(1))), path)
77-
if found is None:
82+
values = set(re.findall(r"'([^']*)'", m.group(1)))
83+
source = path
84+
for added in add_pattern.findall(text):
85+
if source is not None:
86+
values.add(added)
87+
source = path
88+
if source is None:
7889
raise ImportError(
7990
f'no `CREATE TYPE app.job_error` found under {_MIGRATIONS_DIR} — '
8091
'the DB-enum subset check below has nothing to check against.')
92+
found = (frozenset(values), source)
8193
return found
8294

8395

‎backend/invocation.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,7 @@ def _require_declared_artifacts(self, spec: C.MethodSpec,
609609
def _project_completion(self, spec: C.MethodSpec, payload: dict,
610610
out: HandlerResult, envelope: dict,
611611
actor: dict[str, str], job_id: str | None) -> None:
612-
governed = {'data.motif.snapshot', 'ml.motif.train'}
612+
governed = {'data.motif.snapshot', 'ml.motif.train', 'ml.motif.mesh.train'}
613613
if spec.method_id not in governed:
614614
return
615615
if self.motif_governance is None or not hasattr(
@@ -699,6 +699,18 @@ def _execution_identity(self, spec: C.MethodSpec, payload: dict,
699699
handler_source_digest=sha256_digest(source),
700700
checkpoint_digests=[checkpoint] if checkpoint else (),
701701
calibration_digest=calibration,
702-
parameter_digest=sha256_digest(json.dumps(
703-
payload.get('parameters') or {}, sort_keys=True, separators=(',', ':'))),
702+
# The old fallback hashed only payload["parameters"]. Most canonical
703+
# Methods expose typed fields at the payload root, so two different
704+
# molecules or training datasets could receive the same execution
705+
# identity. Hash every scientific request field while excluding only
706+
# release-presentation metadata; retain the Dataset Snapshot reference.
707+
parameter_digest=sha256_digest(json.dumps({
708+
**{key: value for key, value in payload.items() if key != 'registration'},
709+
**({'registration': {
710+
key: payload['registration'][key]
711+
for key in ('dataset_snapshot_ref', 'program_ref', 'campaign_ref',
712+
'identity_policy_release_id', 'source_commit')
713+
if key in payload['registration']}}
714+
if isinstance(payload.get('registration'), dict) else {}),
715+
}, sort_keys=True, separators=(',', ':'), allow_nan=False)),
704716
)

‎backend/jobs.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@
6969
# be a worse outcome than losing the code's precision.
7070
_JOB_ERROR = {
7171
'PARSE', 'UNCONVERGED', 'UNPARAMETERIZED', 'BUDGET',
72-
'UNSUPPORTED', 'TOO_LARGE', 'INTERNAL', 'CANCELLED',
72+
'UNSUPPORTED', 'TOO_LARGE', 'INVALID_PARAMETERS', 'INTERNAL', 'CANCELLED',
7373
}
7474

7575
_EXPECTED_REFUSALS = {
7676
'PARSE', 'UNPARAMETERIZED', 'BUDGET', 'UNSUPPORTED', 'TOO_LARGE',
77+
'INVALID_PARAMETERS',
7778
}
7879

7980

‎backend/method_registry.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,33 @@ def _qm_unit(kind: str, quantity: str, units: str) -> dict:
210210
'ml.motif.predict': ('motif.models',
211211
['predict', 'fingerprint', 'tanimoto', '_dense', '_digest']),
212212
'ml.motif.calibrate': ('motif.models', ['calibrate', '_digest']),
213+
'ml.motif.mesh.train': ('motif.mesh',
214+
['train_predictor_mesh', 'predict_predictor_mesh', '_digest']),
215+
'ml.motif.mesh.predict': ('motif.mesh',
216+
['predict_predictor_mesh', '_digest']),
217+
'design.motif.bayesian_acquire': ('motif.advanced_acquisition',
218+
['botorch_qehvi', 'information_value',
219+
'greedy_diversity', 'selection_sensitivity', '_digest']),
220+
'structure.motif.conformers': ('motif.structure',
221+
['generate_conformers', '_digest']),
222+
'structure.motif.vina': ('motif.docking',
223+
['dock_vina', '_ligand_pdbqt', '_digest']),
224+
'physics.motif.openmm_md': ('motif.physics',
225+
['run_openmm_md', '_sha', '_digest']),
226+
'physics.motif.rbfe_network': ('motif.rbfe',
227+
['plan_rbfe_network', '_chemistry', '_digest']),
228+
'physics.motif.rbfe_aggregate': ('motif.rbfe',
229+
['aggregate_rbfe_results', '_digest']),
213230
}
214231
for _method_id, (_module, _functions) in _MOTIF_UNITS.items():
215232
_descriptor_path = _CONTRACTS / f'{_method_id}.method.json'
216233
_descriptor = json.loads(_descriptor_path.read_text(encoding='utf-8'))
217234
UNITS[_method_id] = {
218235
'module': _module,
219236
'fns': _functions,
220-
'consts': [],
237+
'consts': (['PREDICTOR_SOURCE_DIGESTS'] if _module == 'motif.mesh' else
238+
['ACQUISITION_SOURCE_DIGESTS']
239+
if _module == 'motif.advanced_acquisition' else []),
221240
'descriptor_path': str(_descriptor_path),
222241
'exec_class': 'job',
223242
'in_schema': _descriptor['input']['schema'],

‎backend/motif/acquisition.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ def rank_portfolio(candidates: Iterable[dict[str, Any]], *,
7676
capacity: int) -> dict[str, list[dict[str, Any]]]:
7777
"""Partition every input into selected/reserve/rejected/refused.
7878
79-
Ordering is lexicographic and visible: Pareto rank, missing evidence,
80-
failure risk, cost, then proposal UUID. There is deliberately no weighted total.
79+
Ordering is lexicographic and visible: Pareto rank, missing evidence, expected
80+
Pareto improvement, information value, diversity, failure risk, cost, then
81+
proposal UUID. There is deliberately no weighted total.
8182
"""
8283
if capacity < 0:
8384
raise ValueError("capacity cannot be negative")
@@ -100,6 +101,9 @@ def rank_portfolio(candidates: Iterable[dict[str, Any]], *,
100101
ordered = sorted(eligible, key=lambda row: (
101102
ranks[row["proposal_id"]],
102103
float(row.get("components", {}).get("missing_evidence", 0)),
104+
-float(row.get("components", {}).get("pareto_improvement") or 0),
105+
-float(row.get("components", {}).get("information_value") or 0),
106+
-float(row.get("components", {}).get("diversity") or 0),
103107
float(row.get("components", {}).get("failure_risk", 0)),
104108
float(row.get("components", {}).get("cost", 0)),
105109
row["proposal_id"],
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Auditable Bayesian multi-objective acquisition and portfolio sensitivity."""
2+
from __future__ import annotations
3+
4+
import hashlib
5+
import json
6+
import pathlib
7+
from typing import Any, Iterable
8+
9+
from motif.acquisition import rank_portfolio
10+
11+
12+
_SOURCE_ROOT = pathlib.Path(__file__).resolve().parent
13+
ACQUISITION_SOURCE_DIGESTS = {
14+
name: hashlib.sha256((_SOURCE_ROOT / name).read_bytes()).hexdigest()
15+
for name in ("acquisition.py", "advanced_acquisition.py")
16+
}
17+
18+
19+
def _digest(value: Any) -> str:
20+
return "sha256:" + hashlib.sha256(json.dumps(
21+
value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
22+
23+
24+
def botorch_qehvi(train_features, train_objectives, candidate_features, *,
25+
reference_point: Iterable[float], mc_samples: int = 128,
26+
seed: int = 0) -> dict[str, Any]:
27+
"""Fit an exact multitask GP and score each candidate by q=1 noisy EHVI.
28+
29+
Objective columns must already be oriented so larger is better. Hard constraints
30+
are intentionally outside this function and remain exact Dirac gates.
31+
"""
32+
import torch
33+
from botorch.acquisition.multi_objective import qLogExpectedHypervolumeImprovement
34+
from botorch.fit import fit_gpytorch_mll
35+
from botorch.models import SingleTaskGP
36+
from botorch.sampling.normal import SobolQMCNormalSampler
37+
from botorch.utils.multi_objective.box_decompositions.non_dominated import (
38+
NondominatedPartitioning,
39+
)
40+
from gpytorch.mlls import ExactMarginalLogLikelihood
41+
42+
torch.manual_seed(seed)
43+
x = torch.as_tensor(train_features, dtype=torch.double)
44+
y = torch.as_tensor(train_objectives, dtype=torch.double)
45+
candidates = torch.as_tensor(candidate_features, dtype=torch.double)
46+
ref = torch.as_tensor(list(reference_point), dtype=torch.double)
47+
if x.ndim != 2 or y.ndim != 2 or candidates.ndim != 2:
48+
raise ValueError("qEHVI requires 2D train/candidate matrices")
49+
if len(x) != len(y) or x.shape[1] != candidates.shape[1] or y.shape[1] != len(ref):
50+
raise ValueError("qEHVI matrix dimensions are inconsistent")
51+
if len(x) < 3 or y.shape[1] < 2:
52+
raise ValueError("qEHVI requires >=3 observations and >=2 objectives")
53+
model = SingleTaskGP(x, y)
54+
fit_gpytorch_mll(ExactMarginalLogLikelihood(model.likelihood, model))
55+
partitioning = NondominatedPartitioning(ref_point=ref, Y=y)
56+
sampler = SobolQMCNormalSampler(sample_shape=torch.Size([mc_samples]), seed=seed)
57+
acquisition = qLogExpectedHypervolumeImprovement(
58+
model=model, ref_point=ref.tolist(), partitioning=partitioning, sampler=sampler)
59+
with torch.no_grad():
60+
log_scores = acquisition(candidates.unsqueeze(1)).cpu()
61+
scores = torch.exp(log_scores).tolist()
62+
posterior = model.posterior(candidates)
63+
mean = posterior.mean.cpu().tolist()
64+
variance = posterior.variance.cpu().tolist()
65+
release = {
66+
"schema_version": "1.0", "kind": "botorch_qlogehvi",
67+
"botorch_version": __import__("botorch").__version__,
68+
"reference_point": ref.tolist(), "mc_samples": mc_samples, "seed": seed,
69+
"scores": [float(value) for value in scores],
70+
"posterior_mean": mean, "posterior_variance": variance,
71+
"policy": "q=1 independent candidate scoring; batch diversity applied separately",
72+
}
73+
release["digest"] = _digest(release)
74+
return release
75+
76+
77+
def information_value(posterior_variance: Iterable[Iterable[float]],
78+
costs: Iterable[float]) -> list[float]:
79+
"""Transparent variance-reduction-per-cost proxy used beside, never inside, EHVI."""
80+
import numpy as np
81+
82+
variance = np.asarray(list(posterior_variance), dtype=float)
83+
cost = np.asarray(list(costs), dtype=float)
84+
if variance.ndim != 2 or len(variance) != len(cost) or (cost <= 0).any():
85+
raise ValueError("VOI requires objective variances and strictly positive costs")
86+
return (variance.sum(axis=1) / cost).astype(float).tolist()
87+
88+
89+
def greedy_diversity(similarity, *, order: Iterable[int]) -> list[float]:
90+
"""Score novelty against the already selected batch using a disclosed greedy order."""
91+
import numpy as np
92+
93+
matrix = np.asarray(similarity, dtype=float)
94+
indices = list(order)
95+
if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
96+
raise ValueError("diversity requires a square similarity matrix")
97+
scores = [0.0] * len(matrix)
98+
selected: list[int] = []
99+
for index in indices:
100+
scores[index] = 1.0 if not selected else 1.0 - float(matrix[index, selected].max())
101+
selected.append(index)
102+
return scores
103+
104+
105+
def selection_sensitivity(candidates: Iterable[dict[str, Any]], *,
106+
objectives: Iterable[dict[str, str]],
107+
hard_constraints: Iterable[dict[str, Any]], capacity: int,
108+
relative_perturbations: Iterable[float] = (-0.1, 0.1)) -> dict[str, Any]:
109+
"""Re-run the disclosed policy over objective perturbations and capacity ±1."""
110+
rows = [dict(row) for row in candidates]
111+
axes = list(objectives)
112+
baseline = rank_portfolio(rows, objectives=axes, hard_constraints=hard_constraints,
113+
capacity=capacity)
114+
baseline_ids = {row["proposal_id"] for row in baseline["selected"]}
115+
scenarios = []
116+
capacities = sorted({max(0, capacity - 1), capacity, capacity + 1})
117+
for scenario_capacity in capacities:
118+
result = rank_portfolio(rows, objectives=axes, hard_constraints=hard_constraints,
119+
capacity=scenario_capacity)
120+
selected = {row["proposal_id"] for row in result["selected"]}
121+
union = baseline_ids | selected
122+
scenarios.append({"kind": "capacity", "value": scenario_capacity,
123+
"selected_ids": sorted(selected),
124+
"jaccard": len(baseline_ids & selected) / len(union) if union else 1.0})
125+
for axis in axes:
126+
for perturbation in relative_perturbations:
127+
changed = []
128+
for row in rows:
129+
copy = {**row, "objectives": dict(row["objectives"])}
130+
value = float(copy["objectives"][axis["key"]])
131+
copy["objectives"][axis["key"]] = value * (1.0 + perturbation)
132+
changed.append(copy)
133+
result = rank_portfolio(changed, objectives=axes,
134+
hard_constraints=hard_constraints, capacity=capacity)
135+
selected = {row["proposal_id"] for row in result["selected"]}
136+
union = baseline_ids | selected
137+
scenarios.append({"kind": "objective", "axis": axis["key"],
138+
"relative_perturbation": perturbation,
139+
"selected_ids": sorted(selected),
140+
"jaccard": len(baseline_ids & selected) / len(union) if union else 1.0})
141+
minimum = min((row["jaccard"] for row in scenarios), default=1.0)
142+
report = {"schema_version": "1.0", "kind": "portfolio_sensitivity",
143+
"baseline_selected_ids": sorted(baseline_ids), "scenarios": scenarios,
144+
"minimum_jaccard": minimum,
145+
"unstable": minimum < 0.5}
146+
report["digest"] = _digest(report)
147+
return report
148+
149+
150+
__all__ = [
151+
"botorch_qehvi", "greedy_diversity", "information_value", "selection_sensitivity",
152+
]

0 commit comments

Comments
 (0)