How progress values are deterministically subdivided and mapped through nested execution layers.
map_progress
The core mapping function translates a child’s [0.0, 1.0] progress range into a parent’s sub-range:
pub fn map_progress(child_progress: f32, base: f32, weight: f32) -> f32 {
base + child_progress.clamp(0.0, 1.0) * weight
}
A child reporting 0.5 within a range [base=0.25, weight=0.5] maps to 0.25 + 0.5 * 0.5 = 0.50.
This single function is used everywhere progress is subdivided: a run’s units, a map’s runs, peer call delegation, and UI progress reporting. There are no alternative formulas, and consistency across all layers depends on this one computation.
Source: capdag-rs/src/orchestrator/types.rs (map_progress).
Run-level subdivision
A run is made of units: chains of steps, and maps (Execution). Each unit of a scope has a share in proportion to the steps it holds, and the progress of the scope is a sum, one term per unit:
progress = sum over units of map_progress(unit_progress, 0, share)
For a run of four one-step units, of which the first is done, the second and third are running side by side at 0.5 and 0.2, and the fourth has not started:
| Unit | State | Unit progress | Contributes |
|---|---|---|---|
| 0 | done | 1.0 | 0.250 |
| 1 | running | 0.5 | 0.125 |
| 2 | running | 0.2 | 0.050 |
| 3 | waiting | 0.0 | 0.000 |
| run | 0.425 |
A unit that ends without having run, because it was skipped, and one that failed and was
tolerated, contribute their whole share: the run has nothing further to do for them. Units
run concurrently (Execution), so a unit does not own a
contiguous range of the run: only its term moves. A map is one unit; its own progress is the
same sum over its runs, and it is reported under the map’s step token. Each step’s reported
progress is its own [0, 1], keyed by its step token, which is what a client draws on that
step.
The total is clamped to 1.0 and never reported lower than it was, so neither rounding nor the order in which units report can move a run backwards.
Source: capdag-rs/src/orchestrator/execute_plan.rs.
ProgressMapper
ProgressMapper wraps a CapProgressFn callback with a range subdivision:
pub struct ProgressMapper {
base: f32,
weight: f32,
parent: CapProgressFn,
}
report(child_progress, cap_urn, msg): Maps the child value into the parent range and calls the parent callback:
pub fn report(&self, child_progress: f32, cap_urn: &str, msg: &str) {
let overall = map_progress(child_progress, self.base, self.weight);
(self.parent)(overall, cap_urn, msg);
}
sub_mapper(sub_base, sub_weight): Creates a nested ProgressMapper for further subdivision. The sub-mapper’s effective range is the intersection of the parent’s range and the sub-range. This is how multi-level nesting works: each level narrows the range further.
as_cap_progress_fn(): Converts the mapper into an Arc<dyn Fn(f32, &str, &str) + Send + Sync> for passing to APIs that expect a CapProgressFn.
Source: types.rs (ProgressMapper).
Nested mapping example
graph TD
subgraph "Run (2 one-step units)"
G0["Unit 0<br/>share 0.5"]
G1["Unit 1<br/>share 0.5"]
end
subgraph "Unit 0 handler"
DL["Download<br/>[0.0, 0.25] of handler"]
INF["Inference<br/>[0.25, 1.0] of handler"]
end
subgraph "Effective Ranges"
DLE["Download effective<br/>[0.0, 0.125] of task"]
INFE["Inference effective<br/>[0.125, 0.5] of task"]
end
G0 --> DL
G0 --> INF
DL --> DLE
INF --> INFE
A concrete example showing how a progress value flows through three layers:
Setup: A run of 2 one-step units. An ML cartridge in unit 0 downloads a model via peer call, then runs inference.
Layer 1: the run: Unit 0 has a share of 0.5: its progress moves the run between 0.0 and 0.5.
Layer 2: Handler peer call: The handler reserves [0.0, 0.25] of its own range for the download peer call, and [0.25, 1.0] for inference.
Layer 3: modelcartridge download: Reports progress as files complete.
The value flows:
modelcartridge reports: progress(0.6, "Downloading file 3 of 5")
│
│ Handler's peer call mapping: map_progress(0.6, 0.0, 0.25) = 0.15
│
▼
Handler forwards to CapProgressFn: progress(0.15, "cap:...", "Downloading file 3 of 5")
│
│ DAG group 0 mapping: map_progress(0.15, 0.0, 0.5) = 0.075
│
▼
cap_interpreter maps to task step range: progress(0.075, ...)
│
▼
SQLite: UPDATE task SET progress = 0.075
The same chain when inference is at 50%:
Handler reports: progress(0.5, "Generating token 50 of 100")
│
│ Handler's own range: map_progress(0.5, 0.25, 0.75) = 0.625
│
▼
CapProgressFn: progress(0.625, "cap:...", "Generating token 50 of 100")
│
│ DAG group 0: map_progress(0.625, 0.0, 0.5) = 0.3125
│
▼
SQLite: progress = 0.3125
Progress ratchet
There is no progress ratchet in the database. update_progress_impl does a plain SQL UPDATE, so progress values can go backwards. A handler can emit 0.8 followed by 0.3 and both values are written as-is.
A ratchet (where progress can only increase) is not used, because it would interfere with operations that genuinely revisit earlier states, such as multi-pass encoding or retry loops.
Source: the MachineFabric engine (not part of CapDAG).
Properties
The mapping scheme guarantees:
- Monotonicity within a layer: If child values increase monotonically, mapped values increase monotonically within that unit’s share. Across units the run’s progress is a sum of such terms, so it is monotone however the units interleave.
- Bounded: Mapped values stay within
[base, base + weight]becauseclamp(0.0, 1.0)limits the child’s contribution. - Deterministic: Same inputs always produce the same output, with no random or time-dependent components.
- Non-negative: All mapped values are ≥ 0 (assuming non-negative base and weight).
- Composable: Nested
ProgressMapperinstances compose correctly: the effective range narrows multiplicatively at each level.