inductive-mlxrl 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/CHANGELOG.md +11 -0
  2. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/PKG-INFO +22 -1
  3. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/README.md +21 -0
  4. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/__init__.py +1 -1
  5. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/algorithm.py +3 -0
  6. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/cli.py +34 -0
  7. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/config.py +16 -1
  8. inductive_mlxrl-0.2.2/mlxrl/echo.py +43 -0
  9. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/policy/__init__.py +8 -0
  10. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/policy/trajectory_logprobs.py +117 -0
  11. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/train/grpo.py +4 -0
  12. inductive_mlxrl-0.2.2/mlxrl/train/trajectory.py +557 -0
  13. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/trajectory.py +23 -0
  14. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/pyproject.toml +1 -1
  15. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_config.py +12 -0
  16. inductive_mlxrl-0.2.2/tests/test_echo.py +329 -0
  17. inductive_mlxrl-0.2.1/mlxrl/train/trajectory.py +0 -292
  18. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/.gitignore +0 -0
  19. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/CONTRIBUTING.md +0 -0
  20. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/DESIGN.md +0 -0
  21. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/LICENSE +0 -0
  22. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/THIRD_PARTY_LICENSES.md +0 -0
  23. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/__init__.py +0 -0
  24. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/audit_phase4_config.py +0 -0
  25. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/external_baseline_worker.py +0 -0
  26. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_after.jsonl +0 -0
  27. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_after.md +0 -0
  28. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_after_final.jsonl +0 -0
  29. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_after_final.md +0 -0
  30. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_after_noprefixsync.jsonl +0 -0
  31. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_after_noprefixsync.md +0 -0
  32. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_before.jsonl +0 -0
  33. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate1_mlxrl_before.md +0 -0
  34. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate2_unified_smoke.jsonl +0 -0
  35. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate2_unified_smoke.md +0 -0
  36. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate3_mlx_tune_sync_256.jsonl +0 -0
  37. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate3_mlx_tune_sync_256.md +0 -0
  38. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate3_sync_blocked.jsonl +0 -0
  39. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate3_sync_blocked.md +0 -0
  40. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate3_sync_smoke.jsonl +0 -0
  41. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate3_sync_smoke.md +0 -0
  42. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate4_config_audit.json +0 -0
  43. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate4_config_audit.md +0 -0
  44. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate4_sampler_smoke.jsonl +0 -0
  45. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate4_sampler_smoke.md +0 -0
  46. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate5_full_reconciled.jsonl +0 -0
  47. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/gate5_full_reconciled.md +0 -0
  48. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_external_smoke.jsonl +0 -0
  49. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_external_smoke.md +0 -0
  50. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_full_all_targets.jsonl +0 -0
  51. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_full_all_targets.md +0 -0
  52. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_local_mlxrl_vs_mlx_lm.jsonl +0 -0
  53. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_local_mlxrl_vs_mlx_lm.md +0 -0
  54. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_mlx_lm_g4_smoke.jsonl +0 -0
  55. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/results/phase4_mlx_lm_g4_smoke.md +0 -0
  56. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/benchmarks/run_phase4.py +0 -0
  57. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/examples/qwen35_9b_g2_checkpoint.toml +0 -0
  58. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/examples/qwen3_0_6b_gigpo_recurring.toml +0 -0
  59. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/examples/qwen3_0_6b_grpo.toml +0 -0
  60. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/algo/__init__.py +0 -0
  61. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/algo/gigpo.py +0 -0
  62. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/algo/grpo.py +0 -0
  63. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/data/__init__.py +0 -0
  64. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/data/gsm8k.py +0 -0
  65. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/data/rewards.py +0 -0
  66. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/env/__init__.py +0 -0
  67. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/env/base.py +0 -0
  68. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/env/reference.py +0 -0
  69. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/policy/logprobs.py +0 -0
  70. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/policy/model.py +0 -0
  71. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/py.typed +0 -0
  72. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/rollout/__init__.py +0 -0
  73. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/rollout/agentic.py +0 -0
  74. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/rollout/naive.py +0 -0
  75. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/rollout/optimized.py +0 -0
  76. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/train/__init__.py +0 -0
  77. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/mlxrl/train/reference.py +0 -0
  78. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_agentic_env.py +0 -0
  79. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_agentic_rollout.py +0 -0
  80. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_benchmark_metrics.py +0 -0
  81. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_cli.py +0 -0
  82. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_gigpo.py +0 -0
  83. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_grpo.py +0 -0
  84. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_import_direction.py +0 -0
  85. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_optimized_rollout.py +0 -0
  86. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_policy_logprobs.py +0 -0
  87. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_policy_model.py +0 -0
  88. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_rewards.py +0 -0
  89. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_train_grpo.py +0 -0
  90. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_train_trajectory.py +0 -0
  91. {inductive_mlxrl-0.2.1 → inductive_mlxrl-0.2.2}/tests/test_trajectory_logprobs.py +0 -0
@@ -5,6 +5,17 @@ All notable changes to this project are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project uses semantic versioning once releases are cut.
7
7
 
8
+ ## [0.2.2] - 2026-06-25
9
+
10
+ ### Added
11
+
12
+ - ECHO world-modeling support for tagged multi-turn trajectories, composing an
13
+ independently normalized ECHO SFT loss with the existing GiGPO action loss.
14
+ - `echo_alpha` scheduling with constant and linear-taper-to-zero modes, plus
15
+ separate `loss_echo` and `echo_accuracy` diagnostics.
16
+ - Real-Qwen smoke coverage for tagged ECHO batches on Qwen3-0.6B and
17
+ Qwen3.5-9B MLX 4-bit models.
18
+
8
19
  ## [0.2.1] - 2026-06-25
9
20
 
10
21
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inductive-mlxrl
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Small single-process RL post-training for LLMs on Apple Silicon with MLX.
5
5
  Project-URL: Homepage, https://github.com/inductiveML/mlxrl
6
6
  Project-URL: Repository, https://github.com/inductiveML/mlxrl
@@ -300,6 +300,27 @@ currently exact for token-mean policy losses: base GRPO, DAPO, GSPO token mode,
300
300
  RLOO, and Dr. GRPO with `loss_reduction="token_mean"`. Sequence-reduced losses
301
301
  should keep `micro_batch_size=0`.
302
302
 
303
+ ## ECHO World Modeling
304
+
305
+ ECHO is an additive GiGPO trainer feature for tagged multi-turn trajectories.
306
+ Producer code may attach `token_roles` to each `Trajectory`, aligned to
307
+ `full_token_ids`: `0 = MASKED`, `1 = ACTION`, `2 = ECHO`. ACTION tokens keep
308
+ the normal GiGPO policy-gradient loss. ECHO tokens add an independently
309
+ normalized SFT term, `alpha * NLL`, in the same forward/backward pass.
310
+
311
+ `echo_alpha` defaults to `0.0`, which leaves current GiGPO behavior untouched.
312
+ For early world-modeling, start small, roughly `0.005` to `0.05`, and consider
313
+ `echo_schedule = "linear_taper_to_zero"` with `echo_taper_steps = N` so training
314
+ returns to pure RL. `alpha` is model-size-sensitive; too-high values can
315
+ overfit tool responses or collapse RL.
316
+
317
+ ECHO is content-agnostic by design. `mlxrl` never inspects token text to decide
318
+ what is an environment or tool response. The rollout producer owns the tags:
319
+ MASKED tokens have zero gradient, ACTION tokens receive the RL advantage, and
320
+ ECHO tokens receive only the SFT loss with no KL penalty or importance ratio.
321
+ The trainer logs `loss_echo` and token-level `echo_acc` separately from the
322
+ ACTION loss.
323
+
303
324
  ## Policy Semantics
304
325
 
305
326
  - The base model is frozen before LoRA injection.
@@ -275,6 +275,27 @@ currently exact for token-mean policy losses: base GRPO, DAPO, GSPO token mode,
275
275
  RLOO, and Dr. GRPO with `loss_reduction="token_mean"`. Sequence-reduced losses
276
276
  should keep `micro_batch_size=0`.
277
277
 
278
+ ## ECHO World Modeling
279
+
280
+ ECHO is an additive GiGPO trainer feature for tagged multi-turn trajectories.
281
+ Producer code may attach `token_roles` to each `Trajectory`, aligned to
282
+ `full_token_ids`: `0 = MASKED`, `1 = ACTION`, `2 = ECHO`. ACTION tokens keep
283
+ the normal GiGPO policy-gradient loss. ECHO tokens add an independently
284
+ normalized SFT term, `alpha * NLL`, in the same forward/backward pass.
285
+
286
+ `echo_alpha` defaults to `0.0`, which leaves current GiGPO behavior untouched.
287
+ For early world-modeling, start small, roughly `0.005` to `0.05`, and consider
288
+ `echo_schedule = "linear_taper_to_zero"` with `echo_taper_steps = N` so training
289
+ returns to pure RL. `alpha` is model-size-sensitive; too-high values can
290
+ overfit tool responses or collapse RL.
291
+
292
+ ECHO is content-agnostic by design. `mlxrl` never inspects token text to decide
293
+ what is an environment or tool response. The rollout producer owns the tags:
294
+ MASKED tokens have zero gradient, ACTION tokens receive the RL advantage, and
295
+ ECHO tokens receive only the SFT loss with no KL penalty or importance ratio.
296
+ The trainer logs `loss_echo` and token-level `echo_acc` separately from the
297
+ ACTION loss.
298
+
278
299
  ## Policy Semantics
279
300
 
280
301
  - The base model is frozen before LoRA injection.
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.1"
5
+ __version__ = "0.2.2"
@@ -23,6 +23,9 @@ class AlgorithmLossMetrics:
23
23
  kl: mx.array
24
24
  mean_ratio: mx.array
25
25
  clip_fraction: mx.array
26
+ loss_action: mx.array | None = None
27
+ loss_echo: mx.array | None = None
28
+ echo_accuracy: mx.array | None = None
26
29
 
27
30
 
28
31
  class Algorithm(Protocol):
@@ -38,6 +38,7 @@ from mlxrl.data.gsm8k import (
38
38
  format_gsm8k_prompt,
39
39
  )
40
40
  from mlxrl.data.rewards import accuracy_reward, format_reward
41
+ from mlxrl.echo import EchoSchedule, EchoScheduleName
41
42
  from mlxrl.env import EnvFactory, RecurringStateTextEnv, SingleTurnRewardEnv
42
43
  from mlxrl.policy.logprobs import pad_token_id_from_tokenizer
43
44
  from mlxrl.policy.model import (
@@ -481,6 +482,9 @@ def _namespace_from_train_config(config: TrainConfig) -> argparse.Namespace:
481
482
  max_turns=config.max_turns,
482
483
  rollout_mode=config.rollout_mode,
483
484
  env_name=config.env_name,
485
+ echo_alpha=config.echo_alpha,
486
+ echo_schedule=config.echo_schedule,
487
+ echo_taper_steps=config.echo_taper_steps,
484
488
  )
485
489
 
486
490
 
@@ -500,6 +504,12 @@ def _apply_train_overrides(config: TrainConfig, args: argparse.Namespace) -> Tra
500
504
  replacements["seed"] = args.seed
501
505
  if args.output is not None:
502
506
  replacements["output"] = args.output
507
+ if args.echo_alpha is not None:
508
+ replacements["echo_alpha"] = args.echo_alpha
509
+ if args.echo_schedule is not None:
510
+ replacements["echo_schedule"] = args.echo_schedule
511
+ if args.echo_taper_steps is not None:
512
+ replacements["echo_taper_steps"] = args.echo_taper_steps
503
513
  if args.algorithm is not None:
504
514
  replacements["algorithm"] = replace(
505
515
  config.algorithm,
@@ -575,6 +585,11 @@ def _phase_agentic_train(config: TrainConfig) -> int:
575
585
  )
576
586
  algorithm = _trajectory_algorithm_from_config(config)
577
587
  env_factory = _env_factory_from_config(config)
588
+ echo_schedule = EchoSchedule(
589
+ alpha=config.echo_alpha,
590
+ schedule=cast(EchoScheduleName, config.echo_schedule),
591
+ taper_steps=config.echo_taper_steps,
592
+ )
578
593
  reward_history: list[float] = []
579
594
  kl_history: list[float] = []
580
595
  final_batch = None
@@ -604,6 +619,7 @@ def _phase_agentic_train(config: TrainConfig) -> int:
604
619
  use_checkpoint=config.gradient_checkpointing,
605
620
  compute_reference=config.kl_beta != 0.0,
606
621
  )
622
+ echo_alpha = echo_schedule.value(step)
607
623
  metrics = optimizer_step_trajectory(
608
624
  model=model,
609
625
  optimizer=optimizer,
@@ -613,6 +629,7 @@ def _phase_agentic_train(config: TrainConfig) -> int:
613
629
  algorithm=algorithm,
614
630
  use_checkpoint=config.gradient_checkpointing,
615
631
  micro_batch_size=config.micro_batch_size,
632
+ echo_alpha=echo_alpha,
616
633
  )
617
634
  reward_history.append(metrics.mean_reward)
618
635
  kl_history.append(metrics.kl)
@@ -624,6 +641,9 @@ def _phase_agentic_train(config: TrainConfig) -> int:
624
641
  f"kl={metrics.kl:.6f} "
625
642
  f"ratio={metrics.mean_ratio:.6f} "
626
643
  f"clip={metrics.clip_fraction:.6f} "
644
+ f"echo_alpha={metrics.echo_alpha:.6f} "
645
+ f"loss_echo={metrics.loss_echo:.6f} "
646
+ f"echo_acc={metrics.echo_accuracy:.6f} "
627
647
  f"loss={metrics.loss:.6f}",
628
648
  flush=True,
629
649
  )
@@ -645,7 +665,14 @@ def _phase_agentic_train(config: TrainConfig) -> int:
645
665
  old_policy_logprobs=final_batch.old_policy_logprobs,
646
666
  reference_logprobs=final_batch.reference_logprobs,
647
667
  action_mask=final_batch.action_mask,
668
+ echo_mask=(
669
+ final_batch.echo_mask
670
+ if final_batch.echo_mask is not None
671
+ else mx.zeros_like(final_batch.action_mask)
672
+ ),
648
673
  loss=mx.array(final_metrics.loss, dtype=mx.float32),
674
+ loss_echo=mx.array(final_metrics.loss_echo, dtype=mx.float32),
675
+ echo_accuracy=mx.array(final_metrics.echo_accuracy, dtype=mx.float32),
649
676
  kl=mx.array(final_metrics.kl, dtype=mx.float32),
650
677
  )
651
678
  print(f"reference_output: {output}")
@@ -881,6 +908,13 @@ def build_parser() -> argparse.ArgumentParser:
881
908
  train.add_argument("--available-memory-gb", type=float, default=None)
882
909
  train.add_argument("--auto-fit", action="store_true")
883
910
  train.add_argument("--dry-run", action="store_true")
911
+ train.add_argument("--echo-alpha", type=float, default=None)
912
+ train.add_argument(
913
+ "--echo-schedule",
914
+ choices=["constant", "linear_taper_to_zero"],
915
+ default=None,
916
+ )
917
+ train.add_argument("--echo-taper-steps", type=int, default=None)
884
918
  train.set_defaults(func=_phase_train)
885
919
 
886
920
  phase2 = subparsers.add_parser(
@@ -7,7 +7,9 @@ import re
7
7
  import tomllib
8
8
  from dataclasses import dataclass, field, replace
9
9
  from pathlib import Path
10
- from typing import Any
10
+ from typing import Any, cast
11
+
12
+ from mlxrl.echo import EchoSchedule, EchoScheduleName
11
13
 
12
14
 
13
15
  class ConfigError(ValueError):
@@ -107,6 +109,9 @@ class TrainConfig:
107
109
  iogpu_wired_limit_mb: int | None = None
108
110
  micro_batch_size: int = 0
109
111
  use_chat_template: bool = False
112
+ echo_alpha: float = 0.0
113
+ echo_schedule: str = "constant"
114
+ echo_taper_steps: int | None = None
110
115
  output: str = "reference_outputs/phase1_reference.npz"
111
116
  optimizer: OptimizerConfig = field(default_factory=OptimizerConfig)
112
117
  sampling: SamplingConfigData = field(default_factory=SamplingConfigData)
@@ -147,6 +152,16 @@ class TrainConfig:
147
152
  raise ConfigError("iogpu_wired_limit_mb must be positive or null.")
148
153
  if self.micro_batch_size < 0:
149
154
  raise ConfigError("micro_batch_size must be non-negative.")
155
+ if self.echo_alpha < 0:
156
+ raise ConfigError("echo_alpha must be non-negative.")
157
+ try:
158
+ EchoSchedule(
159
+ alpha=self.echo_alpha,
160
+ schedule=cast(EchoScheduleName, self.echo_schedule),
161
+ taper_steps=self.echo_taper_steps,
162
+ )
163
+ except ValueError as error:
164
+ raise ConfigError(str(error)) from error
150
165
  validate_output_path(self.output)
151
166
  self.optimizer.validate()
152
167
  self.sampling.validate()
@@ -0,0 +1,43 @@
1
+ """ECHO token-role helpers and alpha scheduling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ MASKED = 0
9
+ ACTION = 1
10
+ ECHO = 2
11
+ VALID_TOKEN_ROLES = frozenset({MASKED, ACTION, ECHO})
12
+ EchoScheduleName = Literal["constant", "linear_taper_to_zero"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class EchoSchedule:
17
+ """Schedule ECHO's SFT strength without changing the RL objective."""
18
+
19
+ alpha: float = 0.0
20
+ schedule: EchoScheduleName = "constant"
21
+ taper_steps: int | None = None
22
+
23
+ def __post_init__(self) -> None:
24
+ if self.alpha < 0:
25
+ raise ValueError("ECHO alpha must be non-negative.")
26
+ if self.schedule not in {"constant", "linear_taper_to_zero"}:
27
+ raise ValueError("ECHO schedule must be 'constant' or 'linear_taper_to_zero'.")
28
+ if (
29
+ self.schedule == "linear_taper_to_zero"
30
+ and (self.taper_steps is None or self.taper_steps < 1)
31
+ ):
32
+ raise ValueError("linear_taper_to_zero requires taper_steps >= 1.")
33
+
34
+ def value(self, step: int) -> float:
35
+ """Return alpha for a zero-based optimizer step."""
36
+
37
+ if step < 0:
38
+ raise ValueError("step must be non-negative.")
39
+ if self.schedule == "constant" or self.alpha == 0.0:
40
+ return self.alpha
41
+ assert self.taper_steps is not None
42
+ fraction = max(0.0, 1.0 - (float(step) / float(self.taper_steps)))
43
+ return self.alpha * fraction
@@ -21,10 +21,14 @@ from mlxrl.policy.model import (
21
21
  strict_lora_config,
22
22
  )
23
23
  from mlxrl.policy.trajectory_logprobs import (
24
+ TaggedTrajectoryDualLogprobs,
25
+ TaggedTrajectoryLogprobs,
24
26
  TrajectoryDualLogprobs,
25
27
  TrajectoryLogprobs,
26
28
  trajectory_action_logprobs,
27
29
  trajectory_dual_logprobs,
30
+ trajectory_tagged_dual_logprobs,
31
+ trajectory_tagged_logprobs,
28
32
  )
29
33
 
30
34
  __all__ = [
@@ -34,6 +38,8 @@ __all__ = [
34
38
  "DualLogprobs",
35
39
  "LoRAConfig",
36
40
  "Phase0Report",
41
+ "TaggedTrajectoryDualLogprobs",
42
+ "TaggedTrajectoryLogprobs",
37
43
  "TrajectoryDualLogprobs",
38
44
  "TrajectoryLogprobs",
39
45
  "adapters_disabled",
@@ -48,4 +54,6 @@ __all__ = [
48
54
  "strict_lora_config",
49
55
  "trajectory_action_logprobs",
50
56
  "trajectory_dual_logprobs",
57
+ "trajectory_tagged_dual_logprobs",
58
+ "trajectory_tagged_logprobs",
51
59
  ]
@@ -8,6 +8,7 @@ from dataclasses import dataclass
8
8
  import mlx.core as mx
9
9
  import mlx.nn as nn
10
10
 
11
+ from mlxrl.echo import ACTION, ECHO
11
12
  from mlxrl.policy.logprobs import adapters_disabled, target_logprobs_from_logits
12
13
  from mlxrl.trajectory import Trajectory
13
14
 
@@ -29,6 +30,28 @@ class TrajectoryDualLogprobs:
29
30
  mask: mx.array
30
31
 
31
32
 
33
+ @dataclass(frozen=True)
34
+ class TaggedTrajectoryLogprobs:
35
+ """Full-forward logprobs and masks for tagged trajectory target tokens."""
36
+
37
+ logprobs: mx.array
38
+ target_ids: mx.array
39
+ predictions: mx.array
40
+ action_mask: mx.array
41
+ echo_mask: mx.array
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TaggedTrajectoryDualLogprobs:
46
+ """Policy/reference logprobs for tagged trajectory target tokens."""
47
+
48
+ policy: mx.array
49
+ reference: mx.array
50
+ target_ids: mx.array
51
+ action_mask: mx.array
52
+ echo_mask: mx.array
53
+
54
+
32
55
  def trajectory_action_logprobs(
33
56
  model: nn.Module,
34
57
  trajectories: Sequence[Trajectory],
@@ -83,6 +106,100 @@ def trajectory_action_logprobs(
83
106
  )
84
107
 
85
108
 
109
+ def trajectory_tagged_logprobs(
110
+ model: nn.Module,
111
+ trajectories: Sequence[Trajectory],
112
+ pad_token_id: int = 0,
113
+ use_checkpoint: bool = False,
114
+ ) -> TaggedTrajectoryLogprobs:
115
+ """Gather logprobs for ACTION and ECHO tags from one full forward."""
116
+
117
+ del use_checkpoint
118
+ if not trajectories:
119
+ raise ValueError("At least one trajectory is required.")
120
+ max_sequence_len = max(len(trajectory.full_token_ids) for trajectory in trajectories)
121
+ if max_sequence_len < 2:
122
+ raise ValueError("A trajectory needs at least two tokens for logprobs.")
123
+
124
+ target_width = max_sequence_len - 1
125
+ input_rows: list[list[int]] = []
126
+ target_rows: list[list[int]] = []
127
+ action_mask_rows: list[list[float]] = []
128
+ echo_mask_rows: list[list[float]] = []
129
+ for trajectory in trajectories:
130
+ sequence = list(trajectory.full_token_ids)
131
+ roles = trajectory.token_roles_or_default()
132
+ target_roles = list(roles[1:])
133
+ pad_count = target_width - (len(sequence) - 1)
134
+ input_rows.append(sequence[:-1] + [pad_token_id] * pad_count)
135
+ target_rows.append(sequence[1:] + [pad_token_id] * pad_count)
136
+ action_mask_rows.append(
137
+ [1.0 if role == ACTION else 0.0 for role in target_roles]
138
+ + [0.0] * pad_count
139
+ )
140
+ echo_mask_rows.append(
141
+ [1.0 if role == ECHO else 0.0 for role in target_roles]
142
+ + [0.0] * pad_count
143
+ )
144
+
145
+ input_ids = mx.array(input_rows, dtype=mx.int32)
146
+ target_ids = mx.array(target_rows, dtype=mx.int32)
147
+ logits = model(input_ids)
148
+ logprobs = target_logprobs_from_logits(logits, target_ids)
149
+ predictions = mx.argmax(logits, axis=-1).astype(mx.int32)
150
+ return TaggedTrajectoryLogprobs(
151
+ logprobs=logprobs,
152
+ target_ids=target_ids,
153
+ predictions=predictions,
154
+ action_mask=mx.array(action_mask_rows, dtype=mx.float32),
155
+ echo_mask=mx.array(echo_mask_rows, dtype=mx.float32),
156
+ )
157
+
158
+
159
+ def trajectory_tagged_dual_logprobs(
160
+ model: nn.Module,
161
+ trajectories: Sequence[Trajectory],
162
+ pad_token_id: int = 0,
163
+ use_checkpoint: bool = False,
164
+ compute_reference: bool = True,
165
+ ) -> TaggedTrajectoryDualLogprobs:
166
+ """Compute tagged policy/reference logprobs using one model object."""
167
+
168
+ policy = trajectory_tagged_logprobs(
169
+ model,
170
+ trajectories,
171
+ pad_token_id=pad_token_id,
172
+ use_checkpoint=use_checkpoint,
173
+ )
174
+ if not compute_reference:
175
+ return TaggedTrajectoryDualLogprobs(
176
+ policy=policy.logprobs,
177
+ reference=mx.stop_gradient(policy.logprobs),
178
+ target_ids=policy.target_ids,
179
+ action_mask=policy.action_mask,
180
+ echo_mask=policy.echo_mask,
181
+ )
182
+ with adapters_disabled(model):
183
+ reference = trajectory_tagged_logprobs(
184
+ model,
185
+ trajectories,
186
+ pad_token_id=pad_token_id,
187
+ use_checkpoint=use_checkpoint,
188
+ )
189
+ mx.eval( # Reference sync: materialize tagged ref before restoring adapters.
190
+ reference.logprobs,
191
+ reference.action_mask,
192
+ reference.echo_mask,
193
+ )
194
+ return TaggedTrajectoryDualLogprobs(
195
+ policy=policy.logprobs,
196
+ reference=reference.logprobs,
197
+ target_ids=policy.target_ids,
198
+ action_mask=policy.action_mask,
199
+ echo_mask=policy.echo_mask,
200
+ )
201
+
202
+
86
203
  def trajectory_dual_logprobs(
87
204
  model: nn.Module,
88
205
  trajectories: Sequence[Trajectory],
@@ -50,6 +50,10 @@ class StepMetrics:
50
50
  mean_ratio: float
51
51
  clip_fraction: float
52
52
  mean_reward: float
53
+ loss_action: float = 0.0
54
+ loss_echo: float = 0.0
55
+ echo_accuracy: float = 0.0
56
+ echo_alpha: float = 0.0
53
57
 
54
58
 
55
59
  def _slice_batch(batch: GRPOBatch, start: int, end: int) -> GRPOBatch: