Angshuman28 commited on
Commit
d4edf5d
·
verified ·
1 Parent(s): c1c4162

Upload folder using huggingface_hub

Browse files
CORTEX_FIX_DIAGNOSIS.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cortex GRPO env-interaction failure — diagnosis
2
+
3
+ ## Hypothesis: confirmed
4
+
5
+ The Cortex script fails because **two `EnvClient` instances hold open
6
+ WebSocket sessions to the same HF Space simultaneously** — `env`
7
+ (`make_env()` per outer training iteration) and `candidate_env`
8
+ (`make_env()` once before the while loop, kept alive across all
9
+ candidates). The 1000/OK error is `websockets.ConnectionClosedOK` from
10
+ the gateway closing one session while the other remains live; close-code
11
+ 1000 is the normal-closure code returned on both sides of the dead one.
12
+
13
+ `EnvClient.__init__` does not open a socket; `connect()` is called
14
+ lazily on the first `_send_and_receive`. Both clients open on the first
15
+ tick of the first episode, and from then on we hold two concurrent
16
+ sessions throughout training.
17
+
18
+ ## What the working scripts do
19
+
20
+ - **`minimal_proof.py:score_completion`** — `make_env()` → `reset` →
21
+ `step` → `env.close()` per scoring call, in a `try/finally`. One
22
+ client at a time.
23
+ - **`collect_b3_corpus.py:collect`** — `env = make_env()` per episode,
24
+ reset and step inside, `env.close()` in `finally`. One client at a
25
+ time.
26
+ - **`inference.py:main`** — one env client per task, closed before the
27
+ next task starts.
28
+
29
+ None of them ever hold two clients open concurrently.
30
+
31
+ ## Fix
32
+
33
+ Drop `candidate_env`. Reuse the per-episode `env` for both candidate
34
+ scoring (`reset` + replay prefix + `step`) and committing the best
35
+ action (`reset` + replay prefix + `step`). One WebSocket session per
36
+ training step; `(GROUP_SIZE + 1)` resets per tick.
tests/test_training_multi_model_normalize.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wrapper-projection tests for normalize_step_result (Phase 6c reward fix).
2
+
3
+ The OpenEnv ``StepResult`` wrapper carries ``reward: Optional[float]`` and
4
+ ``done: bool``. The deployed Space's parsed observation arrives with
5
+ ``reward=None``, so ``score_candidate``'s ``0.0`` fallback fires for every
6
+ candidate, group-relative advantages collapse to zero, and the router updates
7
+ against zero gradient. The fix mirrors ``inference.py:_SyncEnvAdapter._normalize``
8
+ (the H15 fix already on main): project the wrapper's ``reward`` and ``done``
9
+ onto the bare observation before returning.
10
+
11
+ Path-load pattern matches ``test_training_multi_model_skeleton.py``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib.util
17
+ import os
18
+ from pathlib import Path
19
+
20
+ from openenv.core.client_types import StepResult
21
+
22
+ from CrisisWorldCortex.models import CrisisworldcortexObservation
23
+
24
+ SCRIPT_PATH = Path(__file__).parent.parent / "training" / "scripts" / "train_cortex_multi_model.py"
25
+
26
+
27
+ def _load_module():
28
+ os.environ.setdefault("HF_TOKEN", "test_token_static_only")
29
+ spec = importlib.util.spec_from_file_location(
30
+ "train_cortex_multi_model_under_test_normalize", SCRIPT_PATH
31
+ )
32
+ assert spec is not None and spec.loader is not None
33
+ module = importlib.util.module_from_spec(spec)
34
+ spec.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ def test_normalize_step_result_projects_wrapper_reward_onto_obs() -> None:
39
+ """Wrapper.reward must be copied onto obs.reward as a float."""
40
+ mod = _load_module()
41
+ obs = CrisisworldcortexObservation()
42
+ wrapper = StepResult(observation=obs, reward=0.42, done=False)
43
+
44
+ result = mod.normalize_step_result(wrapper)
45
+
46
+ assert result.reward == 0.42, (
47
+ f"wrapper.reward=0.42 must be projected onto obs.reward; got {result.reward!r}"
48
+ )
49
+ assert isinstance(result.reward, float)
50
+
51
+
52
+ def test_normalize_step_result_overrides_obs_done_with_wrapper_done() -> None:
53
+ """Wrapper.done is authoritative; obs.done set from wrapper even when False."""
54
+ mod = _load_module()
55
+ obs = CrisisworldcortexObservation(done=True)
56
+ wrapper = StepResult(observation=obs, reward=None, done=False)
57
+
58
+ result = mod.normalize_step_result(wrapper)
59
+
60
+ assert result.done is False, (
61
+ f"wrapper.done=False must override obs.done=True; got result.done={result.done!r}"
62
+ )
63
+
64
+
65
+ def test_normalize_step_result_passthrough_for_bare_observation() -> None:
66
+ """When result has no .observation attr (in-process path), return as-is."""
67
+ mod = _load_module()
68
+ obs = CrisisworldcortexObservation(tick=5)
69
+
70
+ result = mod.normalize_step_result(obs)
71
+
72
+ assert result is obs
73
+ assert result.tick == 5
74
+
75
+
76
+ def test_normalize_step_result_skips_reward_projection_when_wrapper_reward_is_none() -> None:
77
+ """Reset path: wrapper.reward=None must NOT clobber obs.reward to 0.0."""
78
+ mod = _load_module()
79
+ obs = CrisisworldcortexObservation()
80
+ wrapper = StepResult(observation=obs, reward=None, done=False)
81
+
82
+ result = mod.normalize_step_result(wrapper)
83
+
84
+ assert result.reward is None, (
85
+ f"wrapper.reward=None must not be projected; obs.reward should stay None, got {result.reward!r}"
86
+ )
training/scripts/train_cortex_multi_model.py CHANGED
@@ -182,7 +182,24 @@ def make_env() -> Any:
182
 
183
 
184
  def normalize_step_result(result: Any) -> Any:
185
- return result.observation if hasattr(result, "observation") else result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
 
188
  def _action_summary(action: Any) -> str:
@@ -491,6 +508,25 @@ def replay_prefix(env: Any, task: str, seed: int, prefix_actions: List[Any]) ->
491
  return obs
492
 
493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  def score_candidate(
495
  *,
496
  task: str,
@@ -500,6 +536,13 @@ def score_candidate(
500
  completion: str,
501
  brains: Dict[str, FrozenBrain],
502
  ) -> CandidateResult:
 
 
 
 
 
 
 
503
  from CrisisWorldCortex.models import CrisisworldcortexAction
504
 
505
  brain = parse_router_choice(completion)
@@ -616,63 +659,59 @@ def main() -> int:
616
  while update_step < MAX_TRAIN_STEPS:
617
  task = rng.choice(tasks)
618
  seed = rng.randint(0, 10_000_000)
619
- env = make_env()
620
  prefix_actions: List[Any] = []
621
- try:
622
- obs = normalize_step_result(
623
- env.reset(task_name=task, seed=seed, max_ticks=EPISODE_TICKS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  )
625
- last_reward = 0.0
626
- for _tick in range(EPISODE_TICKS):
627
- observation_text = serialize_observation(obs, last_reward)
628
- loss, results = train_step(
629
- router_model=router_model,
630
- router_tokenizer=router_tokenizer,
631
- optimizer=optimizer,
632
- task=task,
633
- seed=seed,
634
- prefix_actions=prefix_actions,
635
- observation_text=observation_text,
636
- brains=brains,
 
 
 
 
 
 
 
 
 
637
  )
638
- update_step += 1
639
- best = max(results, key=lambda result: result.reward)
640
- parse_rate = sum(result.brain is not None for result in results) / len(results)
641
- recent_rewards.append(sum(result.reward for result in results) / len(results))
642
- recent_parse_ok.append(parse_rate)
643
- # The policy update scores all sampled router choices from the
644
- # same state. For the next prefix, keep the best sampled action:
645
- # this turns each episode into a cheap on-policy beam of width
646
- # GROUP_SIZE while still applying exactly one env action per tick.
647
- if best.action is None:
648
- from CrisisWorldCortex.models import CrisisworldcortexAction, NoOp
649
-
650
- best_action = CrisisworldcortexAction(action=NoOp())
651
- else:
652
- best_action = best.action
653
- obs = normalize_step_result(env.step(best_action))
654
- prefix_actions.append(best_action)
655
- last_reward = obs.reward if obs.reward is not None else 0.0
656
- if update_step % LOG_STEPS == 0:
657
- mean_reward = sum(recent_rewards[-LOG_STEPS:]) / min(
658
- len(recent_rewards), LOG_STEPS
659
- )
660
- mean_parse = sum(recent_parse_ok[-LOG_STEPS:]) / min(
661
- len(recent_parse_ok), LOG_STEPS
662
- )
663
- log(
664
- f"step={update_step}/{MAX_TRAIN_STEPS} task={task} "
665
- f"loss={loss:.4f} group_reward={mean_reward:.3f} "
666
- f"parse_success={mean_parse:.0%} best_brain={best.brain}"
667
- )
668
- if SAVE_STEPS > 0 and update_step % SAVE_STEPS == 0:
669
- save_router(
670
- router_model, router_tokenizer, f"{OUTPUT_DIR}/checkpoint-{update_step}"
671
- )
672
- if obs.done or update_step >= MAX_TRAIN_STEPS:
673
- break
674
- finally:
675
- env.close()
676
 
677
  save_router(router_model, router_tokenizer, OUTPUT_DIR)
678
  if PUSH_TO_HUB:
 
182
 
183
 
184
  def normalize_step_result(result: Any) -> Any:
185
+ """Project StepResult wrapper fields onto the bare observation.
186
+
187
+ The HTTP/WebSocket client returns ``StepResult{observation, reward, done}``;
188
+ the deployed Space's parsed observation arrives with ``reward=None``, so
189
+ naively returning ``result.observation`` makes ``score_candidate``'s
190
+ ``0.0`` fallback fire for every candidate, advantages collapse to zero,
191
+ and the router updates against zero gradient. Mirrors
192
+ ``inference.py:_SyncEnvAdapter._normalize`` (the H15 fix on main):
193
+ project wrapper ``reward``/``done`` onto the obs before returning.
194
+ """
195
+ obs = result.observation if hasattr(result, "observation") else result
196
+ wrapper_reward = getattr(result, "reward", None)
197
+ if wrapper_reward is not None:
198
+ obs.reward = float(wrapper_reward)
199
+ wrapper_done = getattr(result, "done", None)
200
+ if wrapper_done is not None:
201
+ obs.done = bool(wrapper_done)
202
+ return obs
203
 
204
 
205
  def _action_summary(action: Any) -> str:
 
508
  return obs
509
 
510
 
511
+ def observe_prefix(task: str, seed: int, prefix_actions: List[Any]) -> Any:
512
+ """Read the current replayed observation with a short-lived env client."""
513
+ env = make_env()
514
+ try:
515
+ return replay_prefix(env, task, seed, prefix_actions)
516
+ finally:
517
+ env.close()
518
+
519
+
520
+ def step_from_prefix(task: str, seed: int, prefix_actions: List[Any], action: Any) -> Any:
521
+ """Replay the committed prefix, submit one action, then close the client."""
522
+ env = make_env()
523
+ try:
524
+ replay_prefix(env, task, seed, prefix_actions)
525
+ return normalize_step_result(env.step(action))
526
+ finally:
527
+ env.close()
528
+
529
+
530
  def score_candidate(
531
  *,
532
  task: str,
 
536
  completion: str,
537
  brains: Dict[str, FrozenBrain],
538
  ) -> CandidateResult:
539
+ """Score one router sample using a short-lived env client.
540
+
541
+ The deployed Space tolerates the lifecycle used by ``minimal_proof.py``:
542
+ create one client, reset/replay, step once, close in ``finally``. Keep
543
+ brain inference outside the env lifetime so the WebSocket is open for
544
+ the shortest possible window and no other client is alive concurrently.
545
+ """
546
  from CrisisWorldCortex.models import CrisisworldcortexAction
547
 
548
  brain = parse_router_choice(completion)
 
659
  while update_step < MAX_TRAIN_STEPS:
660
  task = rng.choice(tasks)
661
  seed = rng.randint(0, 10_000_000)
 
662
  prefix_actions: List[Any] = []
663
+ for _tick in range(EPISODE_TICKS):
664
+ # Env sessions are serialized deliberately. Each tick opens one
665
+ # client to recover the current observation, closes it, then each
666
+ # candidate opens/closes its own scoring client, then the best
667
+ # action opens/closes one commit client. This mirrors the working
668
+ # minimal_proof.py lifecycle and avoids overlapping WebSockets.
669
+ obs = observe_prefix(task, seed, prefix_actions)
670
+ last_reward = obs.reward if obs.reward is not None else 0.0
671
+ observation_text = serialize_observation(obs, last_reward)
672
+ loss, results = train_step(
673
+ router_model=router_model,
674
+ router_tokenizer=router_tokenizer,
675
+ optimizer=optimizer,
676
+ task=task,
677
+ seed=seed,
678
+ prefix_actions=prefix_actions,
679
+ observation_text=observation_text,
680
+ brains=brains,
681
  )
682
+ update_step += 1
683
+ best = max(results, key=lambda result: result.reward)
684
+ parse_rate = sum(result.brain is not None for result in results) / len(results)
685
+ recent_rewards.append(sum(result.reward for result in results) / len(results))
686
+ recent_parse_ok.append(parse_rate)
687
+ # The policy update scores all sampled router choices from the
688
+ # same state. For the next prefix, keep the best sampled action:
689
+ # this turns each episode into a cheap on-policy beam of width
690
+ # GROUP_SIZE while still applying exactly one env action per tick.
691
+ if best.action is None:
692
+ from CrisisWorldCortex.models import CrisisworldcortexAction, NoOp
693
+
694
+ best_action = CrisisworldcortexAction(action=NoOp())
695
+ else:
696
+ best_action = best.action
697
+ obs = step_from_prefix(task, seed, prefix_actions, best_action)
698
+ prefix_actions.append(best_action)
699
+ if update_step % LOG_STEPS == 0:
700
+ mean_reward = sum(recent_rewards[-LOG_STEPS:]) / min(len(recent_rewards), LOG_STEPS)
701
+ mean_parse = sum(recent_parse_ok[-LOG_STEPS:]) / min(
702
+ len(recent_parse_ok), LOG_STEPS
703
  )
704
+ log(
705
+ f"step={update_step}/{MAX_TRAIN_STEPS} task={task} "
706
+ f"loss={loss:.4f} group_reward={mean_reward:.3f} "
707
+ f"parse_success={mean_parse:.0%} best_brain={best.brain}"
708
+ )
709
+ if SAVE_STEPS > 0 and update_step % SAVE_STEPS == 0:
710
+ save_router(
711
+ router_model, router_tokenizer, f"{OUTPUT_DIR}/checkpoint-{update_step}"
712
+ )
713
+ if obs.done or update_step >= MAX_TRAIN_STEPS:
714
+ break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
 
716
  save_router(router_model, router_tokenizer, OUTPUT_DIR)
717
  if PUSH_TO_HUB: