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