Skip to content

Commit 1a38850

Browse files
committed
[fix.fep.ρ.D8+] make eight-ligand design usable and executable
The focused FEP surface was still an expert-only backend projection: ordinary substitution networks disconnected under arbitrary conformer distances, legitimate partial-core chemistry was refused, and the browser could preserve stale or contradictory workflow state. This commit makes the one-click T4L path survive from novice input through eight reviewed poses, a connected OpenFE plan, and exact two-leg system qualification. It also adds adversarial browser and HTTP matrices so malformed inputs, modal/focus failures, responsive overflow, cross-tab state, corrupted receipts, contract bypasses, and job creation regressions remain reproducible.\n\nEvidence: two consecutive clean rounds of 125 FEP tests, 175 RBFE/OpenFE tests plus 10 subtests, hostile browser and HTTP matrices, full eight-ligand campaign qualification, typecheck, both production builds, contract generators, architecture twin, and all 14 repository gates. Physical FEP was not started; the verified no-GPU boundary remained locked.
1 parent af3b472 commit 1a38850

32 files changed

Lines changed: 7396 additions & 4794 deletions

‎backend/motif/openfe_network_planner.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,15 @@ def plan(document: dict) -> dict:
211211
row["id"]: Chem.MolFromSmiles(row["smiles"]).GetNumHeavyAtoms()
212212
for row in compounds
213213
}
214+
# These components are embedded independently only to satisfy GUFE's
215+
# explicit-molecule contract. LoMap still applies ``max3d`` trimming when
216+
# ``threed`` is false, so its default 1 A cutoff can erase a chemically
217+
# obvious 2-D MCS (for example benzene -> toluene) solely because the two
218+
# arbitrary conformers are not superposed. Disable the coordinate cutoff
219+
# for this topology-only planning pass; posed-system preparation performs
220+
# the later receptor-frame geometry check.
214221
lomap = openfe.LomapAtomMapper(time=int(document.get("lomap_timeout", 20)),
215-
threed=False)
222+
threed=False, max3d=0)
216223
kartograf = openfe.KartografAtomMapper(atom_map_hydrogens=False,
217224
allow_bond_breaks=False)
218225
network = ligand_network_planning.generate_minimal_redundant_network(

‎backend/motif/rbfe_campaign_builder.py‎

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,29 @@ def _validate_forcefield_contract(contract: dict) -> dict:
570570
}
571571

572572

573+
def _missing_atom_names(atoms) -> list[str]:
574+
return sorted(str(getattr(atom, "name", atom)) for atom in atoms)
575+
576+
577+
def _missing_residue_segment_witnesses(missing_residues, chain_lengths) -> list[dict]:
578+
witnesses = []
579+
for (chain, index), names in sorted(missing_residues.items()):
580+
chain_index, insertion_index = int(chain), int(index)
581+
modeled_count = int(chain_lengths.get(chain_index, 0))
582+
terminal = insertion_index == 0 or insertion_index == modeled_count
583+
witnesses.append({
584+
"chain_index": chain_index,
585+
"insertion_index": insertion_index,
586+
"residue_names": list(names),
587+
"location": ("n_terminal" if insertion_index == 0 else
588+
"c_terminal" if insertion_index == modeled_count else
589+
"internal"),
590+
"modeled_residue_count": modeled_count,
591+
"terminal_omission": terminal,
592+
})
593+
return witnesses
594+
595+
573596
def _prepare_receptor(pdb_text: str, *, ph: float, keep_waters: bool,
574597
chain_ids: list[str] | None = None,
575598
missing_atoms_policy: str = "auto_repair_report",
@@ -636,22 +659,29 @@ def _prepare_receptor(pdb_text: str, *, ph: float, keep_waters: bool,
636659
f"tautomer/protonation decisions were supplied; witnesses "
637660
f"{histidine_input_witnesses[:10]}")
638661
fixer.findMissingResidues()
639-
unresolved_residues = [
640-
{"chain_index": int(chain), "insertion_index": int(index),
641-
"residue_names": list(names)}
642-
for (chain, index), names in sorted(fixer.missingResidues.items())
643-
]
662+
modeled_chain_lengths = {
663+
index: len(list(chain.residues()))
664+
for index, chain in enumerate(fixer.topology.chains())
665+
}
666+
missing_residue_segments = _missing_residue_segment_witnesses(
667+
fixer.missingResidues, modeled_chain_lengths)
644668
if missing_residues_policy not in {"auto_repair_report", "review_each", "block"}:
645669
raise ValueError(
646670
f"unsupported receptor_policy.missing_residues={missing_residues_policy!r}")
647-
if unresolved_residues:
671+
internal_missing_residues = [
672+
row for row in missing_residue_segments if not row["terminal_omission"]]
673+
if missing_residue_segments and (
674+
missing_residues_policy != "auto_repair_report" or
675+
internal_missing_residues):
648676
raise ValueError(
649677
f"receptor_policy.missing_residues={missing_residues_policy} is UNVERIFIED: "
650-
f"the structure has {len(unresolved_residues)} absent residue segments; "
651-
"PDBFixer loop coordinates are deliberately not invented; first witnesses "
652-
f"{unresolved_residues[:10]}")
678+
f"the structure has {len(missing_residue_segments)} absent residue segments "
679+
f"({len(internal_missing_residues)} internal); PDBFixer loop coordinates are "
680+
"deliberately not invented; first witnesses "
681+
f"{missing_residue_segments[:10]}")
653682
# Building absent loops would invent coordinates. Existing-residue heavy atoms
654-
# are repairable; absent residues stay explicitly unresolved.
683+
# are repairable. Under auto_repair_report, absent terminal tails are retained
684+
# as explicit omissions; internal gaps remain fail-closed.
655685
fixer.missingResidues = {}
656686
fixer.findNonstandardResidues()
657687
nonstandard = [{"residue": str(residue), "replacement": replacement}
@@ -668,12 +698,14 @@ def _prepare_receptor(pdb_text: str, *, ph: float, keep_waters: bool,
668698
missing_atom_witnesses = [{
669699
"chain_id": str(residue.chain.id), "residue_name": str(residue.name),
670700
"residue_number": str(residue.id),
671-
"atom_names": sorted(str(atom.name) for atom in atoms),
701+
"atom_names": _missing_atom_names(atoms),
672702
} for residue, atoms in fixer.missingAtoms.items()]
673703
missing_terminal_witnesses = [{
674704
"chain_id": str(residue.chain.id), "residue_name": str(residue.name),
675705
"residue_number": str(residue.id),
676-
"atom_names": sorted(str(atom.name) for atom in atoms),
706+
# PDBFixer 1.12 exposes internal missing atoms as template Atom
707+
# objects but terminal atoms as names. Preserve either API shape.
708+
"atom_names": _missing_atom_names(atoms),
677709
} for residue, atoms in fixer.missingTerminals.items()]
678710
if missing_atoms_policy not in {"auto_repair_report", "review_each", "block"}:
679711
raise ValueError(
@@ -735,7 +767,8 @@ def _prepare_receptor(pdb_text: str, *, ph: float, keep_waters: bool,
735767
"missing_residues_policy": missing_residues_policy,
736768
"missing_existing_residue_atoms_added": missing_atom_count,
737769
"missing_terminal_atoms_added": missing_terminal_count,
738-
"unresolved_missing_residues": unresolved_residues,
770+
"unresolved_missing_residues": internal_missing_residues,
771+
"omitted_terminal_residue_segments": missing_residue_segments,
739772
"nonstandard_residue_replacements": nonstandard,
740773
"policy_execution": {
741774
"missing_atoms": {
@@ -748,8 +781,13 @@ def _prepare_receptor(pdb_text: str, *, ph: float, keep_waters: bool,
748781
},
749782
"missing_residues": {
750783
"verdict": "CONFIRMED", "policy": missing_residues_policy,
751-
"observed_action": "not_applicable_no_missing_residue_segments",
784+
"observed_action": (
785+
"terminal_absent_segments_omitted_and_reported"
786+
if missing_residue_segments else
787+
"not_applicable_no_missing_residue_segments"),
752788
"unresolved_segment_count": 0,
789+
"omitted_terminal_segment_count": len(missing_residue_segments),
790+
"omitted_terminal_witnesses": missing_residue_segments,
753791
},
754792
"histidines": {
755793
"verdict": "CONFIRMED", "policy": histidines_policy,

‎backend/motif/rbfe_chemistry_evidence.py‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -428,26 +428,30 @@ def mapping_change_evidence(left: Chem.Mol, right: Chem.Mol,
428428
"full_coverage": full_coverage}]),
429429
_dimension(
430430
"ELEMENT", CHANGED if element_changes else (
431-
CONFIRMED if full_coverage else UNVERIFIED),
431+
CONFIRMED if full_coverage or microstate_contract_attached
432+
else UNVERIFIED),
432433
(f"{len(element_changes)} mapped element changes" if element_changes
433434
else "no mapped element change"), element_changes),
434435
_dimension(
435436
"CONNECTIVITY", CHANGED if connectivity_changes else (
436-
CONFIRMED if full_coverage and mapped_left_bonds else UNVERIFIED),
437+
CONFIRMED if ((full_coverage or microstate_contract_attached)
438+
and mapped_left_bonds) else UNVERIFIED),
437439
(f"{len(connectivity_changes)} mapped adjacency changes"
438440
if connectivity_changes else
439441
f"no adjacency change across {mapped_left_bonds} mapped bonds"),
440442
connectivity_changes),
441443
_dimension(
442444
"BOND_ORDER", CHANGED if bond_order_changes else (
443-
CONFIRMED if full_coverage and mapped_left_bonds else UNVERIFIED),
445+
CONFIRMED if ((full_coverage or microstate_contract_attached)
446+
and mapped_left_bonds) else UNVERIFIED),
444447
(f"{len(bond_order_changes)} mapped bond-order changes"
445448
if bond_order_changes else
446449
f"no bond-order change across {mapped_left_bonds} mapped bonds"),
447450
bond_order_changes),
448451
_dimension(
449452
"FORMAL_CHARGE", CHANGED if charge_changes else (
450-
CONFIRMED if full_coverage else UNVERIFIED),
453+
CONFIRMED if full_coverage or microstate_contract_attached
454+
else UNVERIFIED),
451455
(f"{len(charge_changes)} formal-charge changes" if charge_changes
452456
else f"total formal charge {left_charge} in both endpoints"), charge_changes),
453457
_dimension(
@@ -461,7 +465,8 @@ def mapping_change_evidence(left: Chem.Mol, right: Chem.Mol,
461465
stereo_changes + stereo_unverified),
462466
_dimension(
463467
"RING_CYCLE_RANK", CHANGED if left_rank != right_rank else (
464-
CONFIRMED if full_coverage else UNVERIFIED),
468+
CONFIRMED if full_coverage or microstate_contract_attached
469+
else UNVERIFIED),
465470
f"cycle rank {left_rank} -> {right_rank}",
466471
[{"parent_cycle_rank": left_rank, "proposal_cycle_rank": right_rank}]),
467472
_dimension(
@@ -499,6 +504,21 @@ def mapping_change_evidence(left: Chem.Mol, right: Chem.Mol,
499504
"kind": "TAUTOMER_OR_VALENCE_STATE",
500505
"bond_order_witnesses": bond_order_changes,
501506
})
507+
elif (not microstate_comparable and microstate_contract_attached
508+
and not charge_delta):
509+
# A partial common core is the normal RBFE case: adding or removing a
510+
# substituent changes total hydrogen count without implying a hidden
511+
# protonation transition. Once both exact endpoint microstates have
512+
# been server-attested, record that structural delta explicitly rather
513+
# than downgrading the whole transformation to UNVERIFIED.
514+
microstate_changes.append({
515+
"kind": "ATTESTED_ENDPOINTS_WITH_STRUCTURAL_DELTA",
516+
"parent_total_hydrogen_count": left_h,
517+
"proposal_total_hydrogen_count": right_h,
518+
"formal_charge_delta": charge_delta,
519+
"unmapped_parent_heavy_atoms": len(left_unmapped),
520+
"unmapped_proposal_heavy_atoms": len(right_unmapped),
521+
})
502522
elif not microstate_comparable and (hydrogen_delta or charge_delta):
503523
microstate_ambiguity.append({
504524
"kind": "ENDPOINTS_NOT_MICROSTATE_COMPARABLE",
@@ -507,13 +527,20 @@ def mapping_change_evidence(left: Chem.Mol, right: Chem.Mol,
507527
"formal_charge_delta": charge_delta,
508528
})
509529
microstate_verdict = (
510-
CHANGED if microstate_changes else
530+
(CONFIRMED if (microstate_changes and
531+
microstate_changes[0].get("kind") ==
532+
"ATTESTED_ENDPOINTS_WITH_STRUCTURAL_DELTA") else CHANGED)
533+
if microstate_changes else
511534
(UNVERIFIED if microstate_ambiguity else
512535
(CONFIRMED if microstate_contract_attached else UNVERIFIED)))
513536
ledger.append(_dimension(
514537
"PROTONATION_TAUTOMER",
515538
microstate_verdict,
516-
(f"{len(microstate_changes)} protonation/microstate changes" if microstate_changes
539+
("endpoint microstates attested; hydrogen delta is explained by the "
540+
"explicit structural transformation" if (microstate_changes and
541+
microstate_changes[0].get("kind") ==
542+
"ATTESTED_ENDPOINTS_WITH_STRUCTURAL_DELTA") else
543+
f"{len(microstate_changes)} protonation/microstate changes" if microstate_changes
517544
else (f"{len(microstate_ambiguity)} non-diagnostic hydrogen/charge changes"
518545
if microstate_ambiguity else
519546
("explicit endpoint microstate contracts attached"

‎backend/motif/rbfe_references.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ def _canonical(document: dict) -> bytes:
4242
allow_nan=False).encode()
4343

4444

45+
def _receipt_snapshot(response: dict) -> dict:
46+
"""Detach persisted receipt data from the mutable command response."""
47+
return json.loads(json.dumps(response, allow_nan=False))
48+
49+
4550
def _sha(data: bytes | str) -> str:
4651
if isinstance(data, str):
4752
data = data.encode()
@@ -2003,7 +2008,7 @@ def prepare_campaign(self, payload: dict, store,
20032008
"prepared_scientific_ref": prepared_scientific_ref,
20042009
})
20052010
state.pop("pending_changed_domains", None)
2006-
receipt_response = dict(response)
2011+
receipt_response = _receipt_snapshot(response)
20072012
receipt = {
20082013
"request_digest": request_digest,
20092014
"input_version": expected_version,
@@ -2254,6 +2259,7 @@ def accept_poses(self, payload: dict, actor: dict[str, str]) -> dict:
22542259
"Human-reviewed reference-constrained poses. This acceptance does not "
22552260
"constitute an FEP result or validate the pose-generation method."),
22562261
}
2262+
receipt_response = _receipt_snapshot(response)
22572263
state = dict(current["state"])
22582264
state.update({
22592265
"version": output_version, "status": "poses_reviewed",
@@ -2264,7 +2270,7 @@ def accept_poses(self, payload: dict, actor: dict[str, str]) -> dict:
22642270
"request_digest": request_digest,
22652271
"input_version": expected_version,
22662272
"output_version": output_version,
2267-
"response": response, "verdict": "CONFIRMED",
2273+
"response": receipt_response, "verdict": "CONFIRMED",
22682274
},
22692275
})
22702276
state = _advance_scientific_state(

‎backend/tests/test_rbfe_campaign_state.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,25 @@ def test_real_1cbs_polymer_a_reference_b_simple_remove_path_stays_usable():
300300
assert completed.returncode == 0, completed.stdout + completed.stderr
301301

302302

303+
def test_pdbfixer_missing_atom_shapes_and_terminal_residue_omissions_are_normalized():
304+
script = r'''
305+
import sys
306+
from types import SimpleNamespace
307+
sys.path.insert(0, "backend/motif")
308+
import rbfe_campaign_builder as builder
309+
310+
assert builder._missing_atom_names(["OXT", SimpleNamespace(name="H")]) == ["H", "OXT"]
311+
witnesses = builder._missing_residue_segment_witnesses(
312+
{(0, 0): ["MET"], (0, 162): ["ASN", "LEU"], (0, 80): ["GLY"]},
313+
{0: 162},
314+
)
315+
assert [row["location"] for row in witnesses] == ["n_terminal", "internal", "c_terminal"]
316+
assert [row["terminal_omission"] for row in witnesses] == [True, False, True]
317+
'''
318+
completed = _openfe_probe(script)
319+
assert completed.returncode == 0, completed.stdout + completed.stderr
320+
321+
303322
def test_server_histidine_and_terminus_assignments_have_exact_atom_witnesses_manual_is_blocked():
304323
script = r'''
305324
import sys
@@ -585,6 +604,16 @@ def test_scientific_conflicts_do_not_masquerade_as_revision_conflicts():
585604
assert "actual_version" not in payload
586605

587606

607+
def test_receipt_snapshot_cannot_be_mutated_by_later_response_enrichment():
608+
from motif.rbfe_references import _receipt_snapshot
609+
610+
response = {"poses": [{"id": "pose-1"}]}
611+
receipt = _receipt_snapshot(response)
612+
response["campaign_ref"] = {"id": "campaign-1"}
613+
response["poses"][0]["id"] = "mutated"
614+
assert receipt == {"poses": [{"id": "pose-1"}]}
615+
616+
588617
def test_import_receipt_survives_metadata_revision_but_not_science_change():
589618
from motif.rbfe_references import (
590619
_campaign_scientific_ref, _import_receipt_is_current)

‎backend/tests/test_rbfe_chemistry_evidence.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,22 @@ def test_tautomer_bond_pattern_is_changed_but_analogue_hydrogens_are_not() -> No
255255
assert analogue_row["witnesses"][0][
256256
"kind"] == "ENDPOINTS_NOT_MICROSTATE_COMPARABLE"
257257

258+
attested_analogue = mapping_change_evidence(
259+
_mol("c1ccccc1"), _mol("Cc1ccccc1"),
260+
[(index, index + 1) for index in range(6)],
261+
microstate_contract_attached=True)
262+
attested_ledger = _ledger(attested_analogue)
263+
assert attested_analogue["verdict"] == CHANGED
264+
assert attested_ledger["UNMAPPED"]["verdict"] == CHANGED
265+
assert attested_ledger["ELEMENT"]["verdict"] == CONFIRMED
266+
assert attested_ledger["CONNECTIVITY"]["verdict"] == CONFIRMED
267+
assert attested_ledger["BOND_ORDER"]["verdict"] == CONFIRMED
268+
assert attested_ledger["FORMAL_CHARGE"]["verdict"] == CONFIRMED
269+
assert attested_ledger["RING_CYCLE_RANK"]["verdict"] == CONFIRMED
270+
assert attested_ledger["PROTONATION_TAUTOMER"]["verdict"] == CONFIRMED
271+
assert attested_ledger["PROTONATION_TAUTOMER"]["witnesses"][0][
272+
"kind"] == "ATTESTED_ENDPOINTS_WITH_STRUCTURAL_DELTA"
273+
258274

259275
@pytest.mark.parametrize("parent, proposal, change", [
260276
("CC(O)C", "C[C@H](O)F", "ADDED_STEREOCENTER"),
@@ -354,6 +370,26 @@ def test_planner_preserves_campaign_contract_and_all_depictions(
354370
"schema_version"] == "rbfe-depiction-index.v2"
355371

356372

373+
@pytest.mark.skipif(not RUNTIME.is_file(), reason="pinned OpenFE runtime unavailable")
374+
def test_topology_planning_does_not_trim_a_common_core_by_random_3d_distance(
375+
tmp_path: Path) -> None:
376+
network = _plan(tmp_path, {
377+
"compounds": [
378+
{"id": "BEN", "smiles": "c1ccccc1"},
379+
{"id": "TOL", "smiles": "Cc1ccccc1"},
380+
],
381+
"seed": 1729,
382+
"mst_num": 1,
383+
})
384+
assert network["rejected_edges"] == []
385+
assert len(network["edges"]) == 1
386+
edge = network["edges"][0]
387+
assert {edge["left_id"], edge["right_id"]} == {"BEN", "TOL"}
388+
assert edge["mapped_heavy_atom_count"] == 6
389+
assert len(edge["selected_heavy_atom_mapping"]) == 6
390+
assert edge["mapping_score"] >= 0.8
391+
392+
357393
@pytest.mark.skipif(not RUNTIME.is_file(), reason="pinned OpenFE runtime unavailable")
358394
def test_planner_direct_entry_refuses_duplicate_compound_ids(
359395
tmp_path: Path) -> None:

0 commit comments

Comments
 (0)