@@ -287,6 +287,39 @@ impl ExecutionPlan {
287287 Ok ( plan)
288288 }
289289
290+ /// Replace the plan's training-loss roles with a validated canonical set.
291+ ///
292+ /// Roles are grouped by their declared node and sorted by output/phase so
293+ /// every binding produces the same `NodeTask` requirement order. Nodes not
294+ /// present in `roles` are explicitly left without a configurable loss.
295+ pub fn with_training_losses ( mut self , roles : Vec < TrainingLossRoleReference > ) -> Result < Self > {
296+ self . validate ( ) ?;
297+ let mut roles_by_node = BTreeMap :: < NodeId , Vec < TrainingLossRoleReference > > :: new ( ) ;
298+ for role in roles {
299+ role. validate ( ) ?;
300+ if !self . node_plans . contains_key ( & role. node_id ) {
301+ return Err ( DagMlError :: Planning ( format ! (
302+ "training loss references unknown plan node `{}`" ,
303+ role. node_id
304+ ) ) ) ;
305+ }
306+ roles_by_node
307+ . entry ( role. node_id . clone ( ) )
308+ . or_default ( )
309+ . push ( role) ;
310+ }
311+ for node_roles in roles_by_node. values_mut ( ) {
312+ node_roles. sort_by ( |left, right| {
313+ ( & left. output_id , & left. phases ) . cmp ( & ( & right. output_id , & right. phases ) )
314+ } ) ;
315+ }
316+ for ( node_id, node_plan) in & mut self . node_plans {
317+ node_plan. training_losses = roles_by_node. remove ( node_id) . unwrap_or_default ( ) ;
318+ }
319+ self . validate ( ) ?;
320+ Ok ( self )
321+ }
322+
290323 pub fn validate ( & self ) -> Result < ( ) > {
291324 self . graph_plan . graph . validate ( ) ?;
292325 self . campaign . validate ( ) ?;
@@ -2228,6 +2261,87 @@ mod tests {
22282261 }
22292262 }
22302263
2264+ fn custom_loss_role ( node_id : & str , output_id : & str ) -> TrainingLossRoleReference {
2265+ let fixture: serde_json:: Value = serde_json:: from_str ( include_str ! (
2266+ "../../../examples/fixtures/criteria/javascript_local_implementations.v1.json"
2267+ ) )
2268+ . unwrap ( ) ;
2269+ let mut role: TrainingLossRoleReference =
2270+ serde_json:: from_value ( fixture[ "training_loss_role" ] . clone ( ) ) . unwrap ( ) ;
2271+ role. node_id = NodeId :: new ( node_id) . unwrap ( ) ;
2272+ role. output_id = Some ( output_id. to_string ( ) ) ;
2273+ role
2274+ }
2275+
2276+ #[ test]
2277+ fn execution_plan_lowers_training_losses_in_canonical_order ( ) {
2278+ let mut loss_registry = ControllerRegistry :: new ( ) ;
2279+ loss_registry
2280+ . register ( manifest ( "controller:transform" , NodeKind :: Transform ) )
2281+ . unwrap ( ) ;
2282+ let mut model_manifest = manifest ( "controller:model" , NodeKind :: Model ) ;
2283+ model_manifest. capabilities . extend ( [
2284+ ControllerCapability :: SupportsConfigurableLoss ,
2285+ ControllerCapability :: SupportsCustomLoss ,
2286+ ControllerCapability :: SupportsDifferentiableLoss ,
2287+ ] ) ;
2288+ loss_registry. register ( model_manifest) . unwrap ( ) ;
2289+
2290+ let plan = build_execution_plan (
2291+ "plan:training-loss-lowering" ,
2292+ graph ( ) ,
2293+ campaign ( "campaign:training-loss-lowering" ) ,
2294+ & loss_registry,
2295+ )
2296+ . unwrap ( ) ;
2297+ let role_b = custom_loss_role ( "model:pls" , "b" ) ;
2298+ let role_a = custom_loss_role ( "model:pls" , "a" ) ;
2299+ let bound = plan
2300+ . clone ( )
2301+ . with_training_losses ( vec ! [ role_b, role_a. clone( ) ] )
2302+ . unwrap ( ) ;
2303+ let model = bound
2304+ . node_plans
2305+ . get ( & NodeId :: new ( "model:pls" ) . unwrap ( ) )
2306+ . unwrap ( ) ;
2307+ assert_eq ! (
2308+ model
2309+ . training_losses
2310+ . iter( )
2311+ . map( |role| role. output_id. as_deref( ) )
2312+ . collect:: <Vec <_>>( ) ,
2313+ vec![ Some ( "a" ) , Some ( "b" ) ]
2314+ ) ;
2315+
2316+ let cleared = bound. with_training_losses ( Vec :: new ( ) ) . unwrap ( ) ;
2317+ assert ! ( cleared
2318+ . node_plans
2319+ . values( )
2320+ . all( |node| node. training_losses. is_empty( ) ) ) ;
2321+
2322+ let mut unknown = role_a. clone ( ) ;
2323+ unknown. node_id = NodeId :: new ( "model:unknown" ) . unwrap ( ) ;
2324+ assert ! ( plan
2325+ . clone( )
2326+ . with_training_losses( vec![ unknown] )
2327+ . unwrap_err( )
2328+ . to_string( )
2329+ . contains( "unknown plan node" ) ) ;
2330+
2331+ let incapable = build_execution_plan (
2332+ "plan:training-loss-incapable" ,
2333+ graph ( ) ,
2334+ campaign ( "campaign:training-loss-incapable" ) ,
2335+ & registry ( ) ,
2336+ )
2337+ . unwrap ( ) ;
2338+ assert ! ( incapable
2339+ . with_training_losses( vec![ role_a] )
2340+ . unwrap_err( )
2341+ . to_string( )
2342+ . contains( "does not support configurable loss" ) ) ;
2343+ }
2344+
22312345 #[ test]
22322346 fn build_execution_plan_consumes_controller_data_requirements_for_bindings ( ) {
22332347 let model_id = NodeId :: new ( "model:pls" ) . unwrap ( ) ;
0 commit comments