1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
//! Hops, Fuel and the deterministic run cap.
//!
//! These are the rails that keep an unattended factory from running away. The distinction that
//! matters: **Hops bounds depth, not breadth.** A hop is spent per flight and branches inherit
//! the remaining count rather than splitting it, so a branching factor of `b` at `max_hops = h`
//! permits up to `b^h` runs. Fuel is what bounds total work, and the run cap is the deterministic
//! backstop for when a runner does not report its cost.
use crate::flight::ItineraryId;
/// Why the Tower refused to start more work on an itinerary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum Denial {
/// The chain has reached its maximum depth.
#[error("hops exhausted: the chain reached its maximum depth")]
HopsExhausted,
/// The itinerary has spent its shared budget.
#[error("fuel exhausted: the itinerary spent its budget")]
FuelExhausted,
/// The itinerary has started as many runs as it is allowed.
#[error("run cap reached: the itinerary started its maximum number of runs")]
RunCapReached,
/// The whole factory has spent its rolling-window budget.
///
/// Distinct from [`Denial::FuelExhausted`]: this itinerary may have plenty of Fuel left, and
/// still be refused because everything else running has drained the shared Reserve.
#[error("reserve exhausted: the factory spent its budget for the current window")]
ReserveExhausted,
/// Spawning would open an itinerary too many generations from the trigger that began it.
#[error("spawn depth reached: already {max_generations} generation(s) from the trigger")]
SpawnDepthReached {
/// How many generations of spawning are permitted.
max_generations: u32,
},
}
/// Accounting for one causal chain of flights.
#[derive(Debug, Clone)]
pub struct Itinerary {
id: ItineraryId,
max_hops: u32,
fuel_budget_usd: f64,
fuel_spent_usd: f64,
max_runs: u32,
runs_started: u32,
unreported_runs: u32,
generation: u32,
}
impl Itinerary {
/// Opens an itinerary with the given bounds.
#[must_use]
pub fn new(id: ItineraryId, max_hops: u32, fuel_budget_usd: f64, max_runs: u32) -> Self {
Self {
id,
max_hops,
fuel_budget_usd,
fuel_spent_usd: 0.0,
max_runs,
runs_started: 0,
unreported_runs: 0,
generation: 0,
}
}
/// How many spawns separate this itinerary from one a human or a schedule started.
///
/// Zero for a chain a trigger opened. One for a chain an agent spawned, and so on.
#[must_use]
pub fn generation(&self) -> u32 {
self.generation
}
/// Opens a sibling itinerary spawned from this one.
///
/// The generation is the only thing that carries across, and it is the whole point. A spawned
/// itinerary gets *fresh* Hops, fresh Fuel and a fresh run cap — that is what makes per-item
/// work affordable, and it is also exactly what makes spawning unbounded: Hops counts depth
/// within a chain and cannot see across chains, so an agent that spawns an agent that spawns
/// an agent recurses forever while every individual chain stays perfectly inside its rails.
///
/// Generation is the rail that closes that. It is Hops, one level up.
///
/// # Errors
///
/// Returns [`Denial::SpawnDepthReached`] once `max_generations` spawns separate this chain
/// from the trigger that began it.
pub fn spawn(
&self,
id: ItineraryId,
fuel_budget_usd: f64,
max_generations: u32,
) -> Result<Self, Denial> {
let generation = self.generation.saturating_add(1);
if generation > max_generations {
return Err(Denial::SpawnDepthReached { max_generations });
}
Ok(Self {
id,
max_hops: self.max_hops,
fuel_budget_usd,
fuel_spent_usd: 0.0,
max_runs: self.max_runs,
runs_started: 0,
unreported_runs: 0,
generation,
})
}
/// Returns the itinerary identifier.
#[must_use]
pub fn id(&self) -> &ItineraryId {
&self.id
}
/// Hops carried by the first flight of this itinerary.
#[must_use]
pub fn initial_hops(&self) -> u32 {
self.max_hops
}
/// Decides whether a run holding `parent_hops` may send a further flight.
///
/// Returns the hop count the new flight should carry.
///
/// # Errors
///
/// Returns [`Denial::HopsExhausted`] when the chain has reached its depth limit,
/// [`Denial::FuelExhausted`] when the shared budget is spent, or [`Denial::RunCapReached`]
/// when the itinerary has already started its maximum number of runs.
pub fn authorize_send(&self, parent_hops: u32) -> Result<u32, Denial> {
let next = parent_hops.checked_sub(1).ok_or(Denial::HopsExhausted)?;
if next == 0 {
return Err(Denial::HopsExhausted);
}
if self.fuel_exhausted() {
return Err(Denial::FuelExhausted);
}
if self.runs_started >= self.max_runs {
return Err(Denial::RunCapReached);
}
Ok(next)
}
/// Records that a run has been started against this itinerary.
///
/// # Errors
///
/// Returns [`Denial::RunCapReached`] if the cap has already been reached, or
/// [`Denial::FuelExhausted`] if the budget is spent.
pub fn record_run_started(&mut self) -> Result<(), Denial> {
if self.fuel_exhausted() {
return Err(Denial::FuelExhausted);
}
if self.runs_started >= self.max_runs {
return Err(Denial::RunCapReached);
}
self.runs_started += 1;
Ok(())
}
/// Debits reported cost against the shared budget.
///
/// Negative and non-finite values are ignored rather than trusted, because the figure comes
/// from parsing a child process's output.
pub fn debit_fuel(&mut self, usd: f64) {
if usd.is_finite() && usd > 0.0 {
self.fuel_spent_usd += usd;
}
}
/// Records that a completed run reported no cost.
///
/// Fuel is the only bound on breadth, so a runner that reports nothing would otherwise let
/// the rail disappear silently while still appearing to be enforced. The Tower is expected to
/// surface this, and the run cap is what actually holds in its absence.
///
/// A count rather than a flag: "three of forty runs went unmetered" and "every run went
/// unmetered" are the difference between a gap and a broken rail, and a boolean cannot tell
/// them apart.
pub fn note_unreported_cost(&mut self) {
self.unreported_runs = self.unreported_runs.saturating_add(1);
}
/// Returns `true` if any run has completed without reporting its cost.
#[must_use]
pub fn has_cost_reporting_gap(&self) -> bool {
self.unreported_runs > 0
}
/// How many completed runs reported no cost.
#[must_use]
pub fn unreported_runs(&self) -> u32 {
self.unreported_runs
}
/// Fraction of started runs whose cost was actually reported, from 0.0 to 1.0.
///
/// This is what says whether Fuel is metering the itinerary or merely appearing to. A value
/// below 1.0 means the remaining budget is an upper bound, not a measurement.
#[must_use]
pub fn metered_share(&self) -> f64 {
if self.runs_started == 0 {
return 1.0;
}
f64::from(self.runs_started.saturating_sub(self.unreported_runs))
/ f64::from(self.runs_started)
}
/// Returns `true` once the shared budget is spent.
#[must_use]
pub fn fuel_exhausted(&self) -> bool {
self.fuel_spent_usd >= self.fuel_budget_usd
}
/// Budget left, never negative.
#[must_use]
pub fn fuel_remaining_usd(&self) -> f64 {
(self.fuel_budget_usd - self.fuel_spent_usd).max(0.0)
}
/// Number of runs started so far.
#[must_use]
pub fn runs_started(&self) -> u32 {
self.runs_started
}
/// Runs still permitted before the deterministic cap bites.
#[must_use]
pub fn runs_remaining(&self) -> u32 {
self.max_runs.saturating_sub(self.runs_started)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_trigger_opens_generation_zero() {
let itinerary = Itinerary::new(ItineraryId::generate(), 8, 5.0, 64);
assert_eq!(itinerary.generation(), 0);
}
#[test]
fn a_spawned_itinerary_gets_its_own_budget_and_the_next_generation() {
// Fresh Fuel is the point: per-item work wants per-item budget, so that reviewing twelve
// pull requests does not become reviewing four and halting.
let parent = Itinerary::new(ItineraryId::generate(), 8, 5.0, 64);
let child = parent
.spawn(ItineraryId::generate(), 3.0, 2)
.expect("one generation is within the limit");
assert_eq!(child.generation(), 1);
assert_eq!(
child.initial_hops(),
8,
"fresh Hops, not the parent's remainder"
);
assert!((child.fuel_remaining_usd() - 3.0).abs() < 1e-9);
assert_eq!(child.runs_remaining(), 64);
}
#[test]
fn spawning_is_bounded_even_though_every_chain_stays_inside_its_own_rails() {
// The hazard that generation exists for. A spawned itinerary gets fresh Hops, so Hops
// cannot see across chains: an agent that spawns an agent that spawns an agent recurses
// forever while each individual chain looks perfectly well behaved.
let mut current = Itinerary::new(ItineraryId::generate(), 8, 5.0, 64);
for expected in 1..=2 {
current = current
.spawn(ItineraryId::generate(), 5.0, 2)
.expect("within the limit");
assert_eq!(current.generation(), expected);
}
assert_eq!(
current.spawn(ItineraryId::generate(), 5.0, 2).unwrap_err(),
Denial::SpawnDepthReached { max_generations: 2 }
);
}
#[test]
fn a_factory_that_forbids_spawning_says_so_on_the_first_attempt() {
let itinerary = Itinerary::new(ItineraryId::generate(), 8, 5.0, 64);
assert_eq!(
itinerary
.spawn(ItineraryId::generate(), 5.0, 0)
.unwrap_err(),
Denial::SpawnDepthReached { max_generations: 0 }
);
}
fn itinerary() -> Itinerary {
Itinerary::new(ItineraryId::generate(), 8, 5.0, 64)
}
/// Fuel is money, so compare it with a tolerance rather than bit-for-bit.
fn approx(actual: f64, expected: f64) -> bool {
(actual - expected).abs() < 1e-9
}
#[test]
fn a_flight_costs_one_hop() {
let it = itinerary();
assert_eq!(it.authorize_send(8), Ok(7));
assert_eq!(it.authorize_send(7), Ok(6));
}
#[test]
fn the_last_hop_cannot_be_spent() {
let it = itinerary();
assert_eq!(it.authorize_send(1), Err(Denial::HopsExhausted));
assert_eq!(it.authorize_send(0), Err(Denial::HopsExhausted));
}
#[test]
fn spent_fuel_stops_the_chain() {
let mut it = itinerary();
it.debit_fuel(5.0);
assert!(it.fuel_exhausted());
assert_eq!(it.authorize_send(8), Err(Denial::FuelExhausted));
assert!(approx(it.fuel_remaining_usd(), 0.0));
}
#[test]
fn implausible_cost_reports_are_ignored() {
let mut it = itinerary();
it.debit_fuel(-100.0);
it.debit_fuel(f64::NAN);
it.debit_fuel(f64::INFINITY);
assert!(approx(it.fuel_remaining_usd(), 5.0));
}
#[test]
fn hops_alone_do_not_bound_a_fan_out() {
// The finding that moved Fuel into v0.1: hops bound depth, not breadth. Simulate a
// branching factor of three and count how many runs eight hops would permit.
let branching = 3_u32;
let mut runs_at_depth = 1_u32;
let mut total = 0_u32;
let mut hops = 8_u32;
while hops > 1 {
runs_at_depth *= branching;
total += runs_at_depth;
hops -= 1;
}
assert_eq!(total, 3_279);
assert!(
total > 64,
"hops alone permit far more runs than the deterministic cap allows"
);
}
#[test]
fn the_run_cap_bounds_breadth_without_any_cost_reporting() {
let mut it = Itinerary::new(ItineraryId::generate(), 8, 5.0, 3);
for _ in 0..3 {
it.record_run_started().expect("within cap");
}
assert_eq!(it.record_run_started(), Err(Denial::RunCapReached));
assert_eq!(it.authorize_send(8), Err(Denial::RunCapReached));
assert_eq!(it.runs_remaining(), 0);
assert!(!it.fuel_exhausted(), "the cap held with fuel untouched");
}
#[test]
fn a_missing_cost_report_is_remembered() {
let mut it = itinerary();
assert!(!it.has_cost_reporting_gap());
it.note_unreported_cost();
assert!(it.has_cost_reporting_gap());
}
}