Spaces:
Sleeping
Sleeping
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
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:
|