Skip to content

Commit c657b13

Browse files
committed
Continue to id whether model is placed
1 parent 7052939 commit c657b13

6 files changed

Lines changed: 477 additions & 11 deletions

File tree

libtbx/langchain/agent/api_client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ def build_session_state(session_info, session_resolution=None):
9393
if session_info.get("advice_changed"):
9494
session_state["advice_changed"] = True
9595

96+
# S2L: client-side model CRYST1 cell for server-side Tier 1 mismatch check
97+
if session_info.get("unplaced_model_cell"):
98+
session_state["unplaced_model_cell"] = session_info["unplaced_model_cell"]
99+
96100
return session_state
97101

98102

libtbx/langchain/agent/graph_nodes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,8 @@ def perceive(state):
590590
analysis=analysis,
591591
maximum_automation=state.get("maximum_automation", True),
592592
use_yaml_engine=use_yaml_workflow,
593-
directives=state.get("directives", {})
593+
directives=state.get("directives", {}),
594+
session_info=state.get("session_info", {}),
594595
)
595596

596597
# Q1: When the user provides new advice on resume and the workflow is

libtbx/langchain/agent/workflow_engine.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def __init__(self):
5151
# CONTEXT BUILDING
5252
# =========================================================================
5353

54-
def build_context(self, files, history_info, analysis=None, directives=None):
54+
def build_context(self, files, history_info, analysis=None, directives=None, session_info=None):
5555
"""
5656
Build a context dict from files, history, and analysis.
5757
@@ -147,7 +147,9 @@ def build_context(self, files, history_info, analysis=None, directives=None):
147147
# Note: short-circuit against history_info is applied after the
148148
# dict is fully built (see below) — context self-reference is not
149149
# possible inside the literal.
150-
"cell_mismatch": self._check_cell_mismatch(files),
150+
"cell_mismatch": self._check_cell_mismatch(
151+
files,
152+
model_cell=(session_info or {}).get("unplaced_model_cell")),
151153

152154
# Tier 3: diagnostic probe results (set by history after
153155
# phenix.model_vs_data / phenix.map_correlations ran as a probe).
@@ -284,7 +286,7 @@ def _has_search_model(self, files):
284286
"""
285287
return bool(files.get("search_model"))
286288

287-
def _check_cell_mismatch(self, files):
289+
def _check_cell_mismatch(self, files, model_cell=None):
288290
"""
289291
Tier 1 placement check: compare model and data unit cells.
290292
@@ -298,6 +300,10 @@ def _check_cell_mismatch(self, files):
298300
299301
Args:
300302
files (dict): Categorised files dict from the agent.
303+
model_cell (list|tuple|None): Pre-read CRYST1 cell transmitted from
304+
the client (S2L). When supplied, skips opening the PDB file —
305+
which would always fail on the server because the file lives on
306+
the client filesystem and os.path.exists() returns False there.
301307
302308
Returns:
303309
bool: True → definitive mismatch; False → compatible or unknown.
@@ -306,16 +312,62 @@ def _check_cell_mismatch(self, files):
306312
from libtbx.langchain.agent.placement_checker import (
307313
check_xray_cell_mismatch,
308314
check_cryoem_cell_mismatch,
315+
cells_are_compatible,
316+
read_mtz_unit_cell,
317+
read_map_unit_cells,
309318
)
310319
except ImportError:
311320
try:
312321
from agent.placement_checker import (
313322
check_xray_cell_mismatch,
314323
check_cryoem_cell_mismatch,
324+
cells_are_compatible,
325+
read_mtz_unit_cell,
326+
read_map_unit_cells,
315327
)
316328
except ImportError:
317329
return False # Module not available — fail-safe
318330

331+
# ── S2L: use pre-read cell from client when available ────────────────
332+
# The server cannot open files that only exist on the client filesystem.
333+
# When the client transmitted unplaced_model_cell in session_info, use it
334+
# directly for comparison rather than trying to open the PDB file.
335+
if model_cell is not None:
336+
try:
337+
_mc = tuple(float(v) for v in model_cell)
338+
except (TypeError, ValueError):
339+
_mc = None
340+
341+
if _mc is not None:
342+
# X-ray: compare against MTZ (MTZ lives on the server in LocalAgent
343+
# mode, or the comparison is run locally for RemoteAgent via the
344+
# client-side CRYST1 cell here)
345+
mtz_files = files.get("data_mtz", [])
346+
if mtz_files:
347+
mtz_cell = read_mtz_unit_cell(mtz_files[0])
348+
if mtz_cell is not None:
349+
return not cells_are_compatible(_mc, mtz_cell)
350+
return False # MTZ unreadable → fail-safe
351+
352+
# Cryo-EM: compare against map file (map is server-accessible
353+
# because it was just created by resolve_cryo_em on the server,
354+
# or transmitted as a path the server can reach)
355+
map_files = (files.get("full_map", []) or
356+
files.get("optimized_full_map", []) or
357+
files.get("map", []))
358+
if map_files:
359+
full_cell, present_cell = read_map_unit_cells(map_files[0])
360+
if full_cell is None and present_cell is None:
361+
return False # Map unreadable → fail-safe
362+
if full_cell is not None and cells_are_compatible(_mc, full_cell):
363+
return False
364+
if present_cell is not None and cells_are_compatible(_mc, present_cell):
365+
return False
366+
if full_cell is not None or present_cell is not None:
367+
return True # At least one cell readable; model matched neither
368+
return False
369+
370+
# ── Fallback: read cell from file (works for LocalAgent / test mode) ─
319371
# Get the first model file (generic PDB, not a positioned subcategory)
320372
model_files = files.get("model", [])
321373
pdb_path = model_files[0] if model_files else None
@@ -327,8 +379,10 @@ def _check_cell_mismatch(self, files):
327379
if mtz_files:
328380
return check_xray_cell_mismatch(pdb_path, mtz_files[0])
329381

330-
# Cryo-EM: compare against map (full_map preferred, fall back to map)
331-
map_files = files.get("full_map", []) or files.get("map", [])
382+
# Cryo-EM: compare against map (full_map preferred, then optimized, then map)
383+
map_files = (files.get("full_map", []) or
384+
files.get("optimized_full_map", []) or
385+
files.get("map", []))
332386
if map_files:
333387
return check_cryoem_cell_mismatch(pdb_path, map_files[0])
334388

@@ -1805,7 +1859,7 @@ def get_target(self, experiment_type, metric, resolution=None):
18051859
# =========================================================================
18061860

18071861
def get_workflow_state(self, experiment_type, files, history_info, analysis=None,
1808-
directives=None, maximum_automation=True):
1862+
directives=None, maximum_automation=True, session_info=None):
18091863
"""
18101864
Get complete workflow state (compatible with workflow_state.py output).
18111865
@@ -1816,11 +1870,14 @@ def get_workflow_state(self, experiment_type, files, history_info, analysis=None
18161870
analysis: Current log analysis
18171871
directives: Optional user directives dict
18181872
maximum_automation: If False, use stepwise path (process_predicted -> phaser)
1873+
session_info: Optional session state dict; used for client-supplied
1874+
data that the server cannot read directly (e.g.
1875+
unplaced_model_cell — CRYST1 from the client-side PDB)
18191876
18201877
Returns:
18211878
dict: Workflow state compatible with existing code
18221879
"""
1823-
context = self.build_context(files, history_info, analysis, directives)
1880+
context = self.build_context(files, history_info, analysis, directives, session_info)
18241881

18251882
# Add automation_path to context for program filtering
18261883
context["automation_path"] = "automated" if maximum_automation else "stepwise"

libtbx/langchain/agent/workflow_state.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,37 @@ def _analyze_history(history):
939939
_result = _entry.get("result", "")
940940

941941
if _is_failed_result(_result):
942-
continue # Ignore failed cycles for probe detection
942+
# ── S2L: probe failures that carry placement information ──────
943+
# A failed map_correlations run before any refine/dock is the
944+
# placement probe. Some crashes are themselves definitive
945+
# answers — extract the signal before discarding the entry.
946+
#
947+
# "entirely outside map": model coordinates are completely
948+
# outside the map box → model is definitively not placed →
949+
# needs_dock.
950+
#
951+
# Any other failure: mark placement_probed=True with result=None
952+
# (inconclusive) so the probe never runs a third time.
953+
# Routing: placement_uncertain becomes False, falls through to
954+
# obtain_model → predict/dock fallback.
955+
if "map_correlations" in _ecomb and not _seen_refine_or_dock:
956+
_rl = (_result or "").lower()
957+
_outside_signals = [
958+
"entirely outside map",
959+
"outside map",
960+
"model is outside",
961+
"model entirely outside",
962+
"stopping as model",
963+
]
964+
if any(s in _rl for s in _outside_signals):
965+
# Hard evidence: model is not in the map at all
966+
info["placement_probed"] = True
967+
info["placement_probe_result"] = "needs_dock"
968+
elif not info.get("placement_probed"):
969+
# Unknown failure — prevent infinite probe retry
970+
info["placement_probed"] = True
971+
# Leave placement_probe_result as None (inconclusive)
972+
continue # Ignore failed cycles for all other probe detection
943973

944974
# Once refinement or docking has run, anything after is validation
945975
if ("refine" in _ecomb and "real_space" not in _ecomb and
@@ -1132,7 +1162,7 @@ def _clear_zombie_done_flags(history_info, available_files, log_func=None):
11321162

11331163

11341164
def detect_workflow_state(history, available_files, analysis=None, maximum_automation=True,
1135-
use_yaml_engine=True, directives=None):
1165+
use_yaml_engine=True, directives=None, session_info=None):
11361166
"""
11371167
Determine current workflow state based on history and files.
11381168
@@ -1182,7 +1212,7 @@ def detect_workflow_state(history, available_files, analysis=None, maximum_autom
11821212

11831213
engine = WorkflowEngine()
11841214
state = engine.get_workflow_state(experiment_type, files, history_info, analysis,
1185-
directives, maximum_automation)
1215+
directives, maximum_automation, session_info)
11861216

11871217
state["categorized_files"] = state.get("categorized_files", files) # S2c: respect promoted files
11881218
# Set automation_path for both experiment types

libtbx/langchain/docs/project/CHANGELOG.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,142 @@
11
# PHENIX AI Agent - Changelog
22

3+
## Version 112.52 (S2L — Probe Crash Interpreted + Client Cell Transport)
4+
5+
Fixes the apoferritin AIAgent_165 scenario where the placement probe
6+
(`phenix.map_correlations`) crashed with "model is entirely outside map",
7+
ran a second time with the same result, then caused the agent to quit —
8+
never routing to `dock_in_map`.
9+
10+
### Root cause analysis — three independent failures
11+
12+
**Failure 1 — Tier 1 (cell mismatch) is non-functional in production.**
13+
`_check_cell_mismatch` calls `check_cryoem_cell_mismatch(pdb_path, map_path)`
14+
which immediately calls `os.path.exists(pdb_path)`. On the server, `pdb_path`
15+
is `/Users/terwill/.../1aew_A.pdb` — a client-side path. `os.path.exists`
16+
returns `False`. Function returns `False` (fail-safe). The 4× unit cell
17+
mismatch (184 Å F432 crystal vs 32.5 × 39.65 × 36.4 Å P1 sub-box) is never
18+
detected. **Tier 1 has been silently broken for all RemoteAgent users.**
19+
20+
**Failure 2 — The crash itself carries the answer, but the code discards it.**
21+
The probe runs correctly. `map_correlations` raises:
22+
23+
```
24+
Sorry: Stopping as model is entirely outside map and wrapping=False
25+
```
26+
27+
This is a stronger signal than a low CC — it's categorical proof the model
28+
is not placed. But `_analyze_history` has this at the top of the probe loop:
29+
30+
```python
31+
if _is_failed_result(_result):
32+
continue # Ignore failed cycles for probe detection
33+
```
34+
35+
The entire entry is discarded. `placement_probed` stays `False`.
36+
37+
**Failure 3 — The loop.**
38+
With `placement_probed=False`, `placement_uncertain` is still `True` on the
39+
next cycle. The agent runs `map_correlations` again → same crash → same discard
40+
→ eventually the LLM consecutive-failure counter trips and the run stops with
41+
no docking ever attempted.
42+
43+
### Fix 1 — Probe crash detection in `_analyze_history` (workflow_state.py)
44+
45+
Before the `continue`, inspect failed `map_correlations` entries that occurred
46+
before any refine/dock cycle:
47+
48+
```python
49+
if _is_failed_result(_result):
50+
if "map_correlations" in _ecomb and not _seen_refine_or_dock:
51+
_rl = (_result or "").lower()
52+
_outside_signals = [
53+
"entirely outside map", "outside map", "model is outside",
54+
"model entirely outside", "stopping as model",
55+
]
56+
if any(s in _rl for s in _outside_signals):
57+
# Hard evidence: model is not in the map at all
58+
info["placement_probed"] = True
59+
info["placement_probe_result"] = "needs_dock"
60+
elif not info.get("placement_probed"):
61+
# Unknown failure — prevent infinite probe retry
62+
info["placement_probed"] = True
63+
# Leave placement_probe_result as None (inconclusive)
64+
continue
65+
```
66+
67+
Three outcomes:
68+
- **"outside map" crash**`placement_probed=True, result="needs_dock"` → routes to `dock_model`
69+
- **Other crash**`placement_probed=True, result=None` → inconclusive, falls through to `obtain_model`
70+
- **Second failed probe**`placement_probed` already set, no overwrite; guard on `not info.get("placement_probed")` prevents the inconclusive case from clobbering an earlier definitive result
71+
72+
### Fix 2 — Client-side model cell transport (S2L-b)
73+
74+
The client reads the model's CRYST1 cell (which it has access to) and transmits
75+
it in `session_state["unplaced_model_cell"]`. The server uses this pre-read cell
76+
in `_check_cell_mismatch` instead of trying to open the file:
77+
78+
**Client side** (`programs/ai_agent.py`): Before assembling `session_info`,
79+
read the CRYST1 cell from the first unplaced PDB in `active_files` and add it
80+
to `session_info["unplaced_model_cell"]`. Only populated when placement hasn't
81+
been confirmed by history (no `dock_done`, no `refine_done`).
82+
83+
**Transport** (`agent/api_client.py`): `build_session_state` passes
84+
`unplaced_model_cell` through to `session_state`.
85+
86+
**Server receipt** (`phenix_ai/run_ai_agent.py`): Maps `unplaced_model_cell`
87+
from `session_state` into `session_info`.
88+
89+
**Server use** (`agent/workflow_engine.py`): `build_context` passes
90+
`session_info` down to `_check_cell_mismatch(files, model_cell=...)`.
91+
`_check_cell_mismatch` uses the pre-read cell for comparison against the map
92+
(which the server **can** read — it was just created by `resolve_cryo_em`
93+
on the server). Tier 1 now fires correctly in production:
94+
95+
```
96+
model cell = (184, 184, 184, 90, 90, 90) # F432 crystal
97+
map cell = (32.5, 39.65, 36.4, 90, 90, 90) # P1 sub-box
98+
→ mismatch > 5% on all three axes → cell_mismatch=True → dock_model
99+
```
100+
101+
### Also fixed: `optimized_full_map` not checked by `_check_cell_mismatch`
102+
103+
After `resolve_cryo_em`, the output `denmod_map.ccp4` is categorized as
104+
`optimized_full_map` (not `full_map`). The original code only checked
105+
`files.get("full_map") or files.get("map")`. Added `optimized_full_map`
106+
as a priority-2 fallback so the freshly-generated density-modified map is
107+
always found.
108+
109+
### Files changed
110+
111+
| File | Change |
112+
|------|--------|
113+
| `agent/workflow_state.py` | `_analyze_history`: probe-crash handler before `continue`; `detect_workflow_state` gains `session_info` parameter |
114+
| `agent/workflow_engine.py` | `_check_cell_mismatch` rewritten with `model_cell` parameter and S2L fast-path; `optimized_full_map` added to map search; `build_context` accepts `session_info`; `get_workflow_state` accepts `session_info` |
115+
| `agent/api_client.py` | `build_session_state` passes `unplaced_model_cell` through |
116+
| `phenix_ai/run_ai_agent.py` | Maps `unplaced_model_cell` from `session_state``session_info` |
117+
| `programs/ai_agent.py` | Reads CRYST1 cell client-side, adds to `session_info["unplaced_model_cell"]` |
118+
| `agent/graph_nodes.py` | Passes `session_info=state.get("session_info",{})` to `detect_workflow_state` |
119+
| `tests/tst_audit_fixes.py` | 8 new S2L tests (131 total) |
120+
121+
### Corrected cycle trace for AIAgent_165
122+
123+
| Cycle | Program | Why (after fix) |
124+
|-------|---------|-----------------|
125+
| 1 | `phenix.mtriage` | Map analysis |
126+
| 2 | `phenix.resolve_cryo_em` | Half-maps → denmod_map |
127+
| **3** | **`phenix.dock_in_map`** | **Tier 1: cell_mismatch=True (client cell + server map) → dock_model** |
128+
| 4 | `phenix.real_space_refine` | Model docked, refine |
129+
130+
Without the fix, cycle 3 ran `map_correlations` twice (probe crash not interpreted), then stopped.
131+
132+
### Note on test coverage
133+
134+
Tests 5 and 6 (api_client and workflow_engine) are marked SKIP in non-PHENIX
135+
environments because they require the production libtbx import path. They pass
136+
fully in a PHENIX installation. Tests 1–4, 7–8 pass in all environments.
137+
138+
---
139+
3140
## Version 112.48 (S2k — _inject_user_params Empty Program Guard)
4141

5142
Fixes a subtle Python truth-value bug introduced in v112.47 where the

0 commit comments

Comments
 (0)