Spaces:
Running
Running
File size: 33,984 Bytes
bf9e424 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 | """Shared inference helpers for MolForge judge/local runners."""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
try:
from molforge.models import AgentMessage, MolForgeAction, MolForgeObservation
except ImportError:
from models import AgentMessage, MolForgeAction, MolForgeObservation
SYSTEM_PROMPT = """
You control the MolForge specialist team.
Return exactly one JSON object matching this schema.
The top-level "action_type" must be one of exactly:
["edit", "run_assay", "submit", "restart", "defer"].
Never use "proposal", "approval", "objection", "risk_flag", "assay_request",
"rejection", or "submission_recommendation" as the top-level action_type.
Those words are only valid inside messages[].message_type.
{
"action_type": "edit" | "run_assay" | "submit" | "restart" | "defer",
"acting_role": "lead_chemist" | "assay_planner",
"edit_type": "add_fragment" | "substitute" | "remove" | "undo_last_edit" | null,
"slot": "warhead" | "hinge" | "solvent_tail" | "back_pocket" | null,
"fragment": string | null,
"tool_name": "evaluate_properties" | "dock_target" | "assay_toxicity" | "estimate_synthesizability" | "evaluate_novelty" | "search_literature" | "run_md_simulation" | null,
"rationale": string,
"evidence": [string],
"expected_effects": {
"potency": "up" | "down" | "neutral" | "unknown" | "not_applicable",
"toxicity": "up" | "down" | "neutral" | "unknown" | "not_applicable",
"synth": "up" | "down" | "neutral" | "unknown" | "not_applicable",
"novelty": "up" | "down" | "neutral" | "unknown" | "not_applicable",
"budget": "up" | "down" | "neutral" | "unknown" | "not_applicable"
},
"messages": [
{
"sender": "lead_chemist" | "toxicologist" | "assay_planner" | "process_chemist",
"message_type": "proposal" | "approval" | "objection" | "risk_flag" | "assay_request" | "rejection" | "submission_recommendation",
"severity": "low" | "medium" | "high" | "critical",
"summary": string,
"payload": object
}
]
}
Required top-level keys only:
action_type, acting_role, edit_type, slot, fragment, tool_name, rationale,
evidence, expected_effects, messages.
Do not output wrapper keys such as action, role, message_status,
message_payload, sender_role, or explanation_reason.
Use JSON null for unused optional fields.
Use structured specialist messages. Keep rationale short. Evidence must cite only visible observation facts. Expected effects are directional predictions, not hidden scores. Prefer cheap informative assays early, respect safety evidence, and do not submit without adequate support.
Critical role rules:
- lead_chemist may send only proposal, revision_request, or submission_recommendation.
- assay_planner may send proposal, approval, rejection, assay_request, or submission_recommendation.
- toxicologist may send approval, objection, risk_flag, assay_request, or rejection.
- process_chemist may send approval, objection, risk_flag, or assay_request.
- The acting_role should include a proposal message inside messages[].
- Do not use lead_chemist approval messages.
- Do not use toxicologist proposal messages.
- For run_assay, acting_role must be assay_planner. For edit, submit, restart, or defer, acting_role must be lead_chemist.
""".strip()
COMPACT_SYSTEM_PROMPT = """
Return one concise JSON team action only.
Do not explain.
Top-level action_type must be edit, run_assay, submit, restart, or defer.
Never use proposal as action_type; proposal is only a message_type.
Use only the required MolForgeAction top-level keys.
Prioritize finishing the current task with the smallest valid action bundle.
Respect role/message permissions exactly. Never output string "null"; use JSON null.
""".strip()
def heuristic_team_action(observation: MolForgeObservation) -> MolForgeAction:
candidate = select_candidate_action(observation)
attach_reasoning_fields(observation, candidate)
return attach_team_messages(observation, candidate)
def attach_reasoning_fields(
observation: MolForgeObservation,
action: MolForgeAction,
) -> MolForgeAction:
action.evidence = build_action_evidence(observation, action)
action.expected_effects = build_expected_effects(observation, action)
return action
def select_candidate_action(observation: MolForgeObservation) -> MolForgeAction:
current = current_fragments(observation)
known_potency = known_estimate(observation, "potency")
known_toxicity = known_estimate(observation, "toxicity")
known_synth = known_estimate(observation, "synth")
potency_threshold = threshold_value(observation, "potency_min")
toxicity_threshold = threshold_value(observation, "toxicity_max")
synth_threshold = threshold_value(observation, "synth_min")
current_assay_props = current_property_names(observation)
required_evidence = ["potency", "toxicity"] + (["synth"] if synth_threshold is not None else [])
has_required_evidence = all(prop in current_assay_props for prop in required_evidence)
constraints_known_pass = constraints_pass_from_visible_evidence(observation)
post_shift_potency_ready = hard_post_shift_potency_ready(observation)
if has_required_evidence and post_shift_potency_ready and (
constraints_known_pass
or on_planned_final_candidate(observation, current)
or observation.step_index >= observation.max_steps - 1
):
return MolForgeAction(
action_type="submit",
acting_role="lead_chemist",
rationale="Current assay evidence covers potency, toxicity, and feasibility constraints, so the team should submit before spending more budget.",
)
if (
observation.scenario_id == "level_2_hard"
and current["warhead"] != "nitrile"
and observation.remaining_budget >= 350
):
return MolForgeAction(
action_type="restart",
acting_role="lead_chemist",
rationale="The starting series is a known trap under the resistance shift; restart before spending assay budget.",
)
target_edit = planned_fragment_edit(observation, current)
if target_edit is not None:
slot, fragment, rationale = target_edit
return MolForgeAction(
action_type="edit",
acting_role="lead_chemist",
edit_type="substitute",
slot=slot, # type: ignore[arg-type]
fragment=fragment,
rationale=rationale,
)
if (
observation.scenario_id == "level_2_hard"
and not post_shift_potency_ready
and observation.step_index < 3
):
if known_toxicity is None and observation.remaining_budget >= 2000:
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="assay_toxicity",
rationale="Use the pre-shift turns to lock down direct toxicity evidence on the restart scaffold.",
)
if known_synth is None and observation.remaining_budget >= 120:
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="estimate_synthesizability",
rationale="Confirm route feasibility before the target mutation changes the potency readout.",
)
if known_toxicity is None and observation.remaining_budget >= 2000:
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="assay_toxicity",
rationale="The current candidate needs direct toxicity evidence before it can be submitted.",
)
if (
synth_threshold is not None
and known_synth is None
and observation.remaining_budget >= 120
):
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="estimate_synthesizability",
rationale="The current candidate needs explicit synthesizability evidence before submission.",
)
if (
known_potency is None
and observation.remaining_budget >= 300
and can_collect_potency_now(observation)
):
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="dock_target",
rationale="The final decision needs a direct potency readout on the current molecule.",
)
if is_safety_risky(current, known_toxicity, toxicity_threshold):
for slot, fragment, rationale in [
("solvent_tail", "morpholine", "Morpholine typically lowers safety risk while keeping the molecule tractable."),
("back_pocket", "cyano", "Cyano is a safer back-pocket handle than a strongly lipophilic group."),
("warhead", "reversible_cyanoacrylamide", "A softer warhead can preserve potency while reducing reactivity risk."),
("hinge", "azaindole", "Azaindole can recover potency after safer peripheral edits."),
]:
if current[slot] != fragment:
return MolForgeAction(
action_type="edit",
acting_role="lead_chemist",
edit_type="substitute",
slot=slot, # type: ignore[arg-type]
fragment=fragment,
rationale=rationale,
)
if potency_threshold is not None and (known_potency is None or known_potency < potency_threshold):
preferred_warhead = "nitrile" if observation.scenario_id == "level_2_hard" else "acrylamide"
for slot, fragment, rationale in [
("hinge", "azaindole", "Azaindole is the strongest potency-oriented hinge in this library."),
("back_pocket", "cyano", "Cyano improves potency more safely than heavy lipophilic groups."),
("warhead", preferred_warhead, "The warhead should align with the current target context."),
]:
if current[slot] != fragment:
return MolForgeAction(
action_type="edit",
acting_role="lead_chemist",
edit_type="substitute",
slot=slot, # type: ignore[arg-type]
fragment=fragment,
rationale=rationale,
)
if (
known_potency is None
and observation.remaining_budget >= 50
and not has_assay_tool(observation, "evaluate_properties")
):
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="evaluate_properties",
rationale="Use the cheap property panel to cover any remaining potency evidence gap.",
)
if known_potency is None and observation.remaining_budget >= 300:
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="dock_target",
rationale="Potency is still under-characterized, so the team wants a more direct binding readout.",
)
if (
observation.scenario_id == "level_2_hard"
and has_required_evidence
and not post_shift_potency_ready
and observation.remaining_budget >= 300
):
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="dock_target",
rationale="The hard scenario requires post-mutation potency evidence for the submitted molecule.",
)
if synth_threshold is not None and known_synth is not None and known_synth < synth_threshold:
for slot, fragment, rationale in [
("hinge", "pyridine", "Simplifying the hinge improves synthetic tractability."),
("back_pocket", "methoxy", "A smaller back-pocket group reduces route burden."),
]:
if current[slot] != fragment:
return MolForgeAction(
action_type="edit",
acting_role="lead_chemist",
edit_type="substitute",
slot=slot, # type: ignore[arg-type]
fragment=fragment,
rationale=rationale,
)
if has_required_evidence and (post_shift_potency_ready or observation.step_index >= observation.max_steps - 1):
return MolForgeAction(
action_type="submit",
acting_role="lead_chemist",
rationale="The episode horizon is nearly exhausted and current evidence is available, so the team should submit.",
)
if observation.remaining_budget >= 100:
return MolForgeAction(
action_type="run_assay",
acting_role="assay_planner",
tool_name="search_literature",
rationale="The team needs additional qualitative signal before making the next irreversible move.",
)
return MolForgeAction(
action_type="defer",
acting_role="lead_chemist",
rationale="No high-confidence move remains under the current budget.",
)
def attach_team_messages(
observation: MolForgeObservation,
action: MolForgeAction,
) -> MolForgeAction:
messages = [
AgentMessage(
sender=action.acting_role,
message_type="proposal",
severity="medium",
summary=proposal_summary(action),
payload=proposal_payload(action),
)
]
current = current_fragments(observation)
known_potency = known_estimate(observation, "potency")
known_toxicity = known_estimate(observation, "toxicity")
known_synth = known_estimate(observation, "synth")
toxicity_threshold = threshold_value(observation, "toxicity_max")
synth_threshold = threshold_value(observation, "synth_min")
if action.action_type == "run_assay":
messages.append(
AgentMessage(
sender="toxicologist",
message_type="approval",
severity="medium",
summary="Fresh assay evidence improves safety oversight.",
)
)
if action.acting_role != "assay_planner":
messages.append(
AgentMessage(
sender="assay_planner",
message_type="approval",
severity="medium",
summary="This assay is budget-efficient for the current evidence gap.",
)
)
if "process_chemist" in observation.enabled_roles and len(messages) < 4:
messages.append(
AgentMessage(
sender="process_chemist",
message_type="approval",
severity="low",
summary="Additional evidence now will reduce late-stage feasibility surprises.",
)
)
elif action.action_type == "restart":
messages.extend(
[
AgentMessage(
sender="toxicologist",
message_type="approval",
severity="high",
summary="Restarting moves away from the current scaffold safety liabilities.",
),
AgentMessage(
sender="assay_planner",
message_type="approval",
severity="high",
summary="Restarting now is cheaper than polishing a doomed series.",
),
]
)
if "process_chemist" in observation.enabled_roles and len(messages) < 4:
messages.append(
AgentMessage(
sender="process_chemist",
message_type="approval",
severity="medium",
summary="The alternate scaffold family is more tractable to make.",
)
)
elif action.action_type == "submit":
tox_message_type = "approval"
tox_summary = "Visible evidence supports a safe-enough submission."
if known_toxicity is None:
tox_message_type = "assay_request"
tox_summary = "Submission should wait until toxicity has been assayed."
elif toxicity_threshold is not None and known_toxicity > toxicity_threshold:
tox_message_type = "objection"
tox_summary = "Visible toxicity evidence is still above the submission threshold."
messages.append(
AgentMessage(
sender="toxicologist",
message_type=tox_message_type,
severity="high" if tox_message_type != "approval" else "medium",
summary=tox_summary,
)
)
messages.append(
AgentMessage(
sender="assay_planner",
message_type=(
"approval"
if tox_message_type == "approval"
and known_potency is not None
and (synth_threshold is None or known_synth is not None)
else "assay_request"
),
severity="medium",
summary=(
"The team has enough evidence to submit."
if tox_message_type == "approval"
and known_potency is not None
and (synth_threshold is None or known_synth is not None)
else "More evidence is needed before budget should be spent on submission."
),
)
)
if "process_chemist" in observation.enabled_roles and len(messages) < 4:
if known_synth is None and synth_threshold is not None:
process_message_type = "assay_request"
process_summary = "Submission should wait for explicit route feasibility evidence."
elif synth_threshold is not None and known_synth is not None and known_synth < synth_threshold:
process_message_type = "objection"
process_summary = "Submission is premature because the route still looks too fragile."
else:
process_message_type = "approval"
process_summary = "Current route risk looks acceptable for submission."
messages.append(
AgentMessage(
sender="process_chemist",
message_type=process_message_type,
severity="medium",
summary=process_summary,
)
)
elif action.action_type == "edit":
safer_edit = is_safer_edit(current, action, known_toxicity, toxicity_threshold)
messages.append(
AgentMessage(
sender="toxicologist",
message_type="approval" if safer_edit else "risk_flag",
severity="medium",
summary=(
"This edit is directionally safer than the current fragment choice."
if safer_edit
else "This edit could carry additional safety pressure."
),
)
)
messages.append(
AgentMessage(
sender="assay_planner",
message_type="approval",
severity="low",
summary="The edit is cheap enough to try before another expensive assay.",
)
)
if "process_chemist" in observation.enabled_roles and len(messages) < 4:
route_risk = action.slot == "hinge" and action.fragment == "quinazoline"
messages.append(
AgentMessage(
sender="process_chemist",
message_type="approval" if not route_risk else "objection",
severity="low" if not route_risk else "medium",
summary=(
"The route impact looks manageable."
if not route_risk
else "This edit worsens route complexity more than I like."
),
)
)
action.messages = messages[:4]
return action
def proposal_summary(action: MolForgeAction) -> str:
if action.action_type == "edit":
return f"Propose {action.edit_type} on {action.slot} to {action.fragment}."
if action.action_type == "run_assay":
return f"Propose running {action.tool_name}."
if action.action_type == "restart":
return "Propose abandoning the current scaffold and restarting."
if action.action_type == "submit":
return "Propose submitting the current candidate."
return "Propose holding the current state."
def proposal_payload(action: MolForgeAction) -> Dict[str, Any]:
payload = {"action_type": action.action_type}
if action.slot:
payload["slot"] = action.slot
if action.fragment:
payload["fragment"] = action.fragment
if action.tool_name:
payload["tool_name"] = action.tool_name
return payload
def build_action_evidence(
observation: MolForgeObservation,
action: MolForgeAction,
) -> list[str]:
evidence = [
f"scenario={observation.scenario_id}",
f"budget={observation.remaining_budget}/{observation.max_budget}",
f"step={observation.step_index}/{observation.max_steps}",
]
current = current_fragments(observation)
known_props = [
f"{name}={value:.3f}"
for name, value in observation.visible_metrics.items()
if name in {"potency", "toxicity", "synth", "novelty"}
]
if known_props:
evidence.append("visible_metrics:" + ",".join(known_props[:3]))
else:
unknown = [
constraint.name
for constraint in observation.constraint_status
if constraint.evidence_status == "unknown"
]
if unknown:
evidence.append("unknown_constraints:" + ",".join(unknown[:3]))
if action.action_type == "edit" and action.slot and action.fragment:
evidence.append(f"current_{action.slot}={current[action.slot]}")
evidence.append(f"candidate_{action.slot}={action.fragment}")
elif action.action_type == "run_assay" and action.tool_name:
gaps = [
constraint.name
for constraint in observation.constraint_status
if constraint.evidence_status == "unknown"
]
evidence.append(f"tool={action.tool_name}")
if gaps:
evidence.append("evidence_gaps:" + ",".join(gaps[:3]))
elif action.action_type == "submit":
known = [
constraint.name
for constraint in observation.constraint_status
if constraint.evidence_status == "known"
]
evidence.append("known_constraints:" + ",".join(known[:3]) if known else "known_constraints=none")
elif action.action_type == "restart":
evidence.append("restart_available=true")
evidence.append(f"current_molecule={observation.current_molecule}")
return evidence[:5]
def build_expected_effects(
observation: MolForgeObservation,
action: MolForgeAction,
) -> Dict[str, str]:
effects: Dict[str, str] = {
"potency": "unknown",
"toxicity": "unknown",
"synth": "unknown",
"novelty": "unknown",
"budget": "neutral",
}
if action.action_type == "run_assay":
effects.update(
{
"potency": "not_applicable",
"toxicity": "not_applicable",
"synth": "not_applicable",
"novelty": "not_applicable",
"budget": "down",
}
)
return effects
if action.action_type == "submit":
effects.update(
{
"potency": "not_applicable",
"toxicity": "not_applicable",
"synth": "not_applicable",
"novelty": "not_applicable",
"budget": "neutral",
}
)
return effects
if action.action_type == "restart":
effects.update({"toxicity": "down", "synth": "up", "budget": "down"})
if observation.scenario_id == "level_2_hard":
effects["potency"] = "up"
return effects
if action.action_type != "edit":
return effects
fragment = action.fragment or ""
slot = action.slot or ""
if slot == "hinge" and fragment == "azaindole":
effects["potency"] = "up"
if slot == "back_pocket" and fragment == "cyano":
effects["potency"] = "up"
effects["toxicity"] = "down"
if slot == "back_pocket" and fragment in {"chloro", "trifluoromethyl"}:
effects["potency"] = "up"
effects["toxicity"] = "up"
if slot == "solvent_tail" and fragment == "morpholine":
effects["toxicity"] = "down"
effects["synth"] = "up"
if slot == "solvent_tail" and fragment == "dimethylamino":
effects["toxicity"] = "up"
if slot == "warhead" and fragment == "reversible_cyanoacrylamide":
effects["toxicity"] = "down"
effects["novelty"] = "up"
if slot == "warhead" and fragment == "nitrile":
effects["toxicity"] = "down"
if observation.scenario_id == "level_2_hard":
effects["potency"] = "up"
return effects
def current_fragments(observation: MolForgeObservation) -> Dict[str, str]:
return {entry.slot: entry.fragment for entry in observation.molecule_slots}
def known_estimate(observation: MolForgeObservation, property_name: str) -> Optional[float]:
current_signature = observation.current_molecule
for reading in reversed(observation.known_assays):
if reading.molecule_signature == current_signature and reading.property_name == property_name:
return reading.estimate
return None
def current_property_names(observation: MolForgeObservation) -> set[str]:
current_signature = observation.current_molecule
return {
reading.property_name
for reading in observation.known_assays
if reading.molecule_signature == current_signature
}
def has_assay_tool(observation: MolForgeObservation, tool_name: str) -> bool:
current_signature = observation.current_molecule
return any(
reading.molecule_signature == current_signature and reading.tool_name == tool_name
for reading in observation.known_assays
)
def planned_fragment_edit(
observation: MolForgeObservation,
current: Dict[str, str],
) -> Optional[tuple[str, str, str]]:
plans = {
"level_0_easy": [
("solvent_tail", "morpholine", "Morpholine improves safety and keeps synthesis comfortably feasible."),
("back_pocket", "cyano", "Cyano repairs the chloro safety liability while preserving potency."),
("hinge", "azaindole", "Azaindole is needed to clear the stricter potency floor after safety is stabilized."),
],
"level_1_medium": [
("solvent_tail", "morpholine", "First remove the largest safety liability before paying for assays."),
("back_pocket", "cyano", "Cyano keeps potency while avoiding the chloro safety penalty."),
("hinge", "azaindole", "Azaindole recovers enough potency for the tighter medium target."),
],
}
for slot, fragment, rationale in plans.get(observation.scenario_id, []):
if current[slot] != fragment:
return slot, fragment, rationale
return None
def on_planned_final_candidate(
observation: MolForgeObservation,
current: Dict[str, str],
) -> bool:
finals = {
"level_0_easy": {
"warhead": "acrylamide",
"hinge": "azaindole",
"solvent_tail": "morpholine",
"back_pocket": "cyano",
},
"level_1_medium": {
"warhead": "acrylamide",
"hinge": "azaindole",
"solvent_tail": "morpholine",
"back_pocket": "cyano",
},
"level_2_hard": {
"warhead": "nitrile",
"hinge": "azaindole",
"solvent_tail": "morpholine",
"back_pocket": "cyano",
},
}
return current == finals.get(observation.scenario_id, {})
def can_collect_potency_now(observation: MolForgeObservation) -> bool:
return observation.scenario_id != "level_2_hard" or observation.step_index >= 3
def hard_post_shift_potency_ready(observation: MolForgeObservation) -> bool:
if observation.scenario_id != "level_2_hard":
return True
current_signature = observation.current_molecule
return any(
reading.molecule_signature == current_signature
and reading.property_name == "potency"
and observation.step_index >= 4
for reading in observation.known_assays
)
def constraints_pass_from_visible_evidence(observation: MolForgeObservation) -> bool:
if not observation.constraint_status:
return False
return all(
constraint.evidence_status == "known" and constraint.satisfied is True
for constraint in observation.constraint_status
)
def threshold_value(observation: MolForgeObservation, constraint_name: str) -> Optional[float]:
for constraint in observation.constraint_status:
if constraint.name != constraint_name:
continue
try:
return float(constraint.target.split()[-1])
except Exception:
return None
return None
def is_safety_risky(
fragments: Dict[str, str],
known_toxicity: Optional[float],
toxicity_threshold: Optional[float],
) -> bool:
if known_toxicity is not None and toxicity_threshold is not None and known_toxicity > toxicity_threshold:
return True
risky_patterns = [
fragments["solvent_tail"] == "dimethylamino",
fragments["back_pocket"] == "trifluoromethyl",
fragments["hinge"] == "fluorophenyl" and fragments["back_pocket"] == "chloro",
]
return any(risky_patterns)
def is_safer_edit(
current: Dict[str, str],
action: MolForgeAction,
known_toxicity: Optional[float],
toxicity_threshold: Optional[float],
) -> bool:
if action.slot == "solvent_tail" and action.fragment == "morpholine":
return True
if action.slot == "back_pocket" and action.fragment == "cyano":
return True
if action.slot == "warhead" and action.fragment == "reversible_cyanoacrylamide":
return True
if known_toxicity is not None and toxicity_threshold is not None:
return known_toxicity <= toxicity_threshold
return current["solvent_tail"] != "dimethylamino"
def extract_json(text: str) -> Dict[str, Any]:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or start >= end:
raise ValueError("No JSON object found in model response")
return json.loads(text[start : end + 1])
def build_model_payload(
observation: MolForgeObservation,
*,
compact: bool,
) -> Dict[str, Any]:
base_payload = {
"valid_top_level_action_types": ["edit", "run_assay", "submit", "restart", "defer"],
"invalid_top_level_action_types": [
"proposal",
"approval",
"objection",
"risk_flag",
"assay_request",
"rejection",
"submission_recommendation",
],
"scenario_id": observation.scenario_id,
"difficulty": observation.difficulty,
"task_brief": observation.task_brief,
"state_label": observation.state_label,
"state_path_tail": observation.state_path[-4:],
"current_molecule": observation.current_molecule,
"current_smiles": observation.metadata.get("current_smiles", ""),
"oracle_backend": observation.metadata.get("oracle_backend", {}),
"visible_metrics": observation.visible_metrics,
"constraint_status": [constraint.model_dump() for constraint in observation.constraint_status],
"governance": observation.governance.model_dump(),
"last_transition_summary": observation.last_transition_summary,
"allowed_actions": observation.allowed_actions,
"role_message_rules": {
"lead_chemist": ["proposal", "revision_request", "submission_recommendation"],
"assay_planner": ["proposal", "approval", "rejection", "assay_request", "submission_recommendation"],
"toxicologist": ["approval", "objection", "risk_flag", "assay_request", "rejection"],
"process_chemist": ["approval", "objection", "risk_flag", "assay_request"],
},
"remaining_budget": observation.remaining_budget,
"step_index": observation.step_index,
"max_steps": observation.max_steps,
}
if compact:
base_payload["known_assays"] = [
{
"tool_name": reading.tool_name,
"property_name": reading.property_name,
"estimate": reading.estimate,
"confidence_low": reading.confidence_low,
"confidence_high": reading.confidence_high,
}
for reading in observation.known_assays[-6:]
]
base_payload["role_summaries"] = [
{
"role": role.role,
"local_objective": role.local_objective,
"key_fields": list(role.observation.keys())[:5],
}
for role in observation.role_observations
]
return base_payload
base_payload["known_assays"] = [reading.model_dump() for reading in observation.known_assays]
base_payload["role_observations"] = [role.model_dump() for role in observation.role_observations]
base_payload["recent_messages"] = [message.model_dump() for message in observation.message_log[-6:]]
return base_payload
|