@@ -114,6 +114,7 @@ class Model(
114114 _openai_client : AsyncOpenAI | None = None
115115 _wandb_run : Optional ["Run" ] = None # Private, for lazy wandb initialization
116116 _wandb_defined_metrics : set [str ]
117+ _wandb_config : dict [str , Any ]
117118 _run_start_time : float
118119 _run_start_monotonic : float
119120 _last_local_train_log_monotonic : float
@@ -150,6 +151,7 @@ def __init__(
150151 ** kwargs ,
151152 )
152153 object .__setattr__ (self , "_wandb_defined_metrics" , set ())
154+ object .__setattr__ (self , "_wandb_config" , {})
153155 object .__setattr__ (self , "_run_start_time" , time .time ())
154156 object .__setattr__ (self , "_run_start_monotonic" , time .monotonic ())
155157 object .__setattr__ (
@@ -371,6 +373,34 @@ def _deep_merge_dicts(
371373 merged [key ] = value
372374 return merged
373375
376+ @staticmethod
377+ def _merge_wandb_config (
378+ existing : dict [str , Any ],
379+ updates : dict [str , Any ],
380+ * ,
381+ path : str = "" ,
382+ ) -> dict [str , Any ]:
383+ merged = dict (existing )
384+ for key , value in updates .items ():
385+ key_path = f"{ path } .{ key } " if path else key
386+ if key not in merged :
387+ merged [key ] = value
388+ continue
389+ existing_value = merged [key ]
390+ if isinstance (existing_value , dict ) and isinstance (value , dict ):
391+ merged [key ] = Model ._merge_wandb_config (
392+ existing_value ,
393+ value ,
394+ path = key_path ,
395+ )
396+ continue
397+ if existing_value != value :
398+ raise ValueError (
399+ "W&B config is immutable once set. "
400+ f"Conflicting value for '{ key_path } '."
401+ )
402+ return merged
403+
374404 def read_state (self ) -> StateType | None :
375405 """Read persistent state from the model directory.
376406
@@ -390,6 +420,43 @@ def read_state(self) -> StateType | None:
390420 with open (state_path , "r" ) as f :
391421 return json .load (f )
392422
423+ def update_wandb_config (
424+ self ,
425+ config : dict [str , Any ],
426+ ) -> None :
427+ """Merge configuration into the W&B run config for this model.
428+
429+ This can be called before the W&B run exists, in which case the config is
430+ passed to `wandb.init(...)` when ART first creates the run. If the run is
431+ already active, ART updates the run config immediately.
432+
433+ Args:
434+ config: JSON-serializable configuration to store on the W&B run.
435+ """
436+ if not isinstance (config , dict ):
437+ raise TypeError ("config must be a dict[str, Any]" )
438+
439+ merged = self ._merge_wandb_config (self ._wandb_config , config )
440+ object .__setattr__ (self , "_wandb_config" , merged )
441+
442+ if self ._wandb_run is not None and not self ._wandb_run ._is_finished :
443+ self ._sync_wandb_config (self ._wandb_run )
444+
445+ def _sync_wandb_config (
446+ self ,
447+ run : "Run" ,
448+ ) -> None :
449+ if not self ._wandb_config :
450+ return
451+
452+ run_config = getattr (run , "config" , None )
453+ if run_config is None or not hasattr (run_config , "update" ):
454+ return
455+
456+ run_config .update (
457+ self ._wandb_config ,
458+ )
459+
393460 def _get_wandb_run (self ) -> Optional ["Run" ]:
394461 """Get or create the wandb run for this model."""
395462 import wandb
@@ -401,6 +468,7 @@ def _get_wandb_run(self) -> Optional["Run"]:
401468 project = self .project ,
402469 name = self .name ,
403470 id = self .name ,
471+ config = self ._wandb_config or None ,
404472 resume = "allow" ,
405473 settings = wandb .Settings (
406474 x_stats_open_metrics_endpoints = {
@@ -436,6 +504,7 @@ def _get_wandb_run(self) -> Optional["Run"]:
436504 wandb .define_metric ("val/*" , step_metric = "training_step" )
437505 wandb .define_metric ("test/*" , step_metric = "training_step" )
438506 wandb .define_metric ("discarded/*" , step_metric = "training_step" )
507+ self ._sync_wandb_config (run )
439508 return self ._wandb_run
440509
441510 def _log_metrics (
0 commit comments