Skip to content

Commit dd27d47

Browse files
committed
feat(wasm): execute task-local losses through scheduler
1 parent d23866b commit dd27d47

9 files changed

Lines changed: 509 additions & 52 deletions

crates/dag-ml-core/src/plan.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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();

crates/dag-ml-wasm/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,43 @@ requirements exactly match its active roles. A controller may copy the returned
7676
attestation into `NodeResult.lineage.loss_attestations` only after `loss(...)`
7777
returns successfully.
7878

79+
For scheduler execution, lower the roles into the native plan and execute that
80+
plan rather than rebuilding a loss-free campaign plan:
81+
82+
```js
83+
const planJson = build_execution_plan_with_training_losses_json(
84+
planId,
85+
JSON.stringify(graph),
86+
JSON.stringify(campaign),
87+
JSON.stringify(controllerManifests),
88+
JSON.stringify(trainingLossRoles),
89+
);
90+
91+
const resultsJson = execute_execution_plan_phase_json(
92+
planJson,
93+
JSON.stringify(controllerManifests),
94+
runId,
95+
rootSeed,
96+
"FIT_CV",
97+
(controllerId, taskJson, exactSeed) =>
98+
controller.invoke(controllerId, taskJson, exactSeed),
99+
);
100+
```
101+
102+
The native lowerer replaces all plan loss roles, groups them by node and sorts
103+
them canonically before validating controller capabilities. At execution, every
104+
manifest embedded in the plan must exactly match the independently supplied
105+
trusted controller registry before any callback runs. The scheduler then checks
106+
that each callback result contains exactly the task's required loss attestations
107+
in the same order.
108+
109+
The callback's `exactSeed` argument is a decimal string (or `null`), avoiding
110+
precision loss for native `u64` seeds beyond JavaScript's safe-integer range.
111+
The callback may set `NodeResult.lineage.seed` to `null`; the WASM bridge then
112+
injects the authoritative native seed before scheduler validation. The returned
113+
`resultsJson` still contains native numeric `u64` values; use a lossless JSON
114+
integer parser, or preserve the raw JSON, when inspecting lineage seeds exactly.
115+
79116
JavaScript-local descriptors use `binding:javascript` and a `host_local` or
80117
`portable_registered` lifecycle. A Web Worker must populate its own registry;
81118
functions are not cloned, posted, or embedded in replay artifacts. Resolution

0 commit comments

Comments
 (0)