- 1
//! Choosing which commitment to advance next. - 2
//! - 3
//! Once vak holds obligations rather than a single conversation, something has - 4
//! to decide what gets worked on. That decision is **deterministic and - 5
//! inspectable** rather than a learned policy: every commitment's priority - 6
//! decomposes into named components a person can read and argue with, and the - 7
//! user can always pin one to the front. - 8
//! - 9
//! An opaque scheduler in a system whose entire thesis is auditability would - 10
//! be the one place you could not ask "why did it do that". - 11
- 12
use serde::{Deserialize, Serialize}; - 13
- 14
use vak_intent::Stakes; - 15
- 16
use crate::types::{Commitment, Phase}; - 17
- 18
/// Why a commitment scored where it did. - 19
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 20
pub struct Priority { - 21
pub commitment_id: String, - 22
pub score: f64, - 23
/// Named contributions, largest first. This is the explanation. - 24
pub components: Vec<(String, f64)>, - 25
/// Set when the commitment cannot be worked right now, with the reason. - 26
#[serde(default, skip_serializing_if = "Option::is_none")] - 27
pub withheld: Option<String>, - 28
} - 29
- 30
impl Priority { - 31
pub fn is_runnable(&self) -> bool { - 32
self.withheld.is_none() - 33
} - 34
- 35
/// One line for `vak commit list` or the admin portfolio view. - 36
pub fn explain(&self) -> String { - 37
match &self.withheld { - 38
Some(reason) => format!("held: {reason}"), - 39
None => { - 40
let parts: Vec<String> = self - 41
.components - 42
.iter() - 43
.map(|(name, value)| format!("{name} {value:+.2}")) - 44
.collect(); - 45
format!("{:.2} = {}", self.score, parts.join(", ")) - 46
} - 47
} - 48
} - 49
} - 50
- 51
/// Inputs the scheduler cannot read off a commitment itself. - 52
#[derive(Debug, Clone, Default)] - 53
pub struct SchedulerContext { - 54
pub now: Option<chrono::DateTime<chrono::Utc>>, - 55
/// Commitment ids the user pinned to the front. - 56
pub pinned: Vec<String>, - 57
} - 58
- 59
impl SchedulerContext { - 60
fn now(&self) -> chrono::DateTime<chrono::Utc> { - 61
self.now.unwrap_or_else(chrono::Utc::now) - 62
} - 63
} - 64
- 65
/// Score one commitment. - 66
pub fn prioritize(commitment: &Commitment, context: &SchedulerContext) -> Priority { - 67
let now = context.now(); - 68
let mut components: Vec<(String, f64)> = Vec::new(); - 69
let mut withheld = None; - 70
- 71
// --- reasons not to run -------------------------------------------- - 72
if commitment.phase.is_terminal() { - 73
withheld = Some("closed".to_string()); - 74
} else if commitment.phase == Phase::Suspended { - 75
withheld = Some( - 76
commitment - 77
.suspension - 78
.as_ref() - 79
.map(|suspension| suspension.describe()) - 80
.unwrap_or_else(|| "suspended".into()), - 81
); - 82
} else if commitment.phase == Phase::Blocked { - 83
withheld = Some( - 84
commitment - 85
.blocker - 86
.clone() - 87
.unwrap_or_else(|| "blocked".into()), - 88
); - 89
} else if commitment.is_over_budget() { - 90
// Budget exhaustion holds the work rather than failing it: a human can - 91
// raise the ceiling, and destroying the commitment would throw away - 92
// everything it had established. - 93
withheld = Some(format!( - 94
"lifetime budget exhausted (${:.2})", - 95
commitment.spend_usd - 96
)); - 97
} else if commitment.is_expired(now) { - 98
withheld = Some("past its relevance window".to_string()); - 99
} else if commitment.is_stalled() { - 100
withheld = Some(format!( - 101
"stalled for {} consecutive episodes", - 102
commitment.consecutive_stalls - 103
)); - 104
} - 105
- 106
// --- pin ----------------------------------------------------------- - 107
if context.pinned.contains(&commitment.commitment_id) { - 108
// A pin dominates every computed factor. The user's explicit choice is - 109
// not something a heuristic gets to outvote. - 110
components.push(("pinned".into(), 1000.0)); - 111
} - 112
- 113
// --- stakes -------------------------------------------------------- - 114
let stakes_weight = match commitment.spec.reading.stakes { - 115
Stakes::Irreversible => 4.0, - 116
Stakes::Costly => 3.0, - 117
Stakes::Reversible => 2.0, - 118
Stakes::Inert => 1.0, - 119
}; - 120
components.push(("stakes".into(), stakes_weight)); - 121
- 122
// --- deadline proximity -------------------------------------------- - 123
if let Some(expiry) = commitment.spec.economics.expires_at { - 124
let hours_left = (expiry - now).num_minutes() as f64 / 60.0; - 125
if hours_left > 0.0 { - 126
// Rises sharply as the window closes; 24h out is worth ~1, an hour - 127
// out is worth ~24. - 128
components.push(("deadline".into(), (24.0 / hours_left).min(50.0))); - 129
} - 130
} - 131
- 132
// --- staleness ----------------------------------------------------- - 133
let idle_hours = (now - commitment.updated_at).num_minutes() as f64 / 60.0; - 134
if idle_hours > 0.0 { - 135
// Logarithmic: something untouched for a week should surface, but not - 136
// so hard that it starves urgent work. - 137
components.push(("staleness".into(), (1.0 + idle_hours).ln().max(0.0))); - 138
} - 139
- 140
// --- progress ------------------------------------------------------ - 141
// Work that is nearly done is worth finishing before work that has barely - 142
// started; a half-finished commitment is a liability. - 143
let total = commitment.criteria.len(); - 144
if total > 0 { - 145
let passed = commitment.criteria.iter().filter(|c| c.passed()).count(); - 146
components.push(("progress".into(), 2.0 * (passed as f64 / total as f64))); - 147
} - 148
- 149
// --- review due ---------------------------------------------------- - 150
if let Some(hours) = commitment.spec.economics.review_every_hours - 151
&& idle_hours >= f64::from(hours) - 152
{ - 153
components.push(("review-due".into(), 3.0)); - 154
} - 155
- 156
components.sort_by(|a, b| { - 157
b.1.partial_cmp(&a.1) - 158
.unwrap_or(std::cmp::Ordering::Equal) - 159
.then_with(|| a.0.cmp(&b.0)) - 160
}); - 161
let score = components.iter().map(|(_, value)| value).sum(); - 162
- 163
Priority { - 164
commitment_id: commitment.commitment_id.clone(), - 165
score, - 166
components, - 167
withheld, - 168
} - 169
} - 170
- 171
/// Rank a portfolio, highest priority first. - 172
/// - 173
/// Held commitments sort after runnable ones but are still returned, because - 174
/// "why is nothing happening" is a question the portfolio view must be able to - 175
/// answer without a second query. - 176
pub fn rank(commitments: &[Commitment], context: &SchedulerContext) -> Vec<Priority> { - 177
let mut out: Vec<Priority> = commitments - 178
.iter() - 179
.map(|commitment| prioritize(commitment, context)) - 180
.collect(); - 181
out.sort_by(|a, b| { - 182
a.is_runnable() - 183
.cmp(&b.is_runnable()) - 184
.reverse() - 185
.then_with(|| { - 186
b.score - 187
.partial_cmp(&a.score) - 188
.unwrap_or(std::cmp::Ordering::Equal) - 189
}) - 190
// Total order: ties break on id so the ranking is stable across - 191
// runs and reproducible in a test. - 192
.then_with(|| a.commitment_id.cmp(&b.commitment_id)) - 193
}); - 194
out - 195
} - 196
- 197
/// The next commitment to advance, if any is runnable. - 198
pub fn next(commitments: &[Commitment], context: &SchedulerContext) -> Option<String> { - 199
rank(commitments, context) - 200
.into_iter() - 201
.find(|priority| priority.is_runnable()) - 202
.map(|priority| priority.commitment_id) - 203
} - 204
- 205
#[cfg(test)] - 206
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 207
mod tests { - 208
use super::*; - 209
use crate::types::{CommitmentSpec, Economics, Phase}; - 210
use vak_intent::{Reading, Satisfaction}; - 211
- 212
fn commitment(id: &str, stakes: Stakes) -> Commitment { - 213
let now = chrono::Utc::now(); - 214
Commitment { - 215
commitment_id: id.into(), - 216
opened_at: now, - 217
spec: CommitmentSpec { - 218
objective: format!("objective {id}"), - 219
reading: Reading { - 220
stakes, - 221
..Reading::general() - 222
}, - 223
criteria: Vec::new(), - 224
min_satisfaction: Satisfaction::Asserted, - 225
economics: Economics::default(), - 226
cwd: std::path::PathBuf::from("/tmp"), - 227
supersedes: None, - 228
thread_id: None, - 229
audience_id: None, - 230
}, - 231
phase: Phase::Active, - 232
criteria: Vec::new(), - 233
episodes: Vec::new(), - 234
suspension: None, - 235
blocker: None, - 236
envelope: None, - 237
closure: None, - 238
superseded_by: None, - 239
spend_usd: 0.0, - 240
consecutive_stalls: 0, - 241
drift: Vec::new(), - 242
updated_at: now, - 243
} - 244
} - 245
- 246
fn context() -> SchedulerContext { - 247
SchedulerContext { - 248
now: Some(chrono::Utc::now()), - 249
pinned: Vec::new(), - 250
} - 251
} - 252
- 253
#[test] - 254
fn ranking_is_deterministic() { - 255
let commitments = vec![ - 256
commitment("b", Stakes::Reversible), - 257
commitment("a", Stakes::Reversible), - 258
]; - 259
let context = context(); - 260
assert_eq!(rank(&commitments, &context), rank(&commitments, &context),); - 261
} - 262
- 263
#[test] - 264
fn a_pin_outranks_every_computed_factor() { - 265
let mut urgent = commitment("urgent", Stakes::Irreversible); - 266
urgent.spec.economics.expires_at = Some(chrono::Utc::now() + chrono::Duration::minutes(30)); - 267
let boring = commitment("boring", Stakes::Inert); - 268
let context = SchedulerContext { - 269
now: Some(chrono::Utc::now()), - 270
pinned: vec!["boring".into()], - 271
}; - 272
assert_eq!(next(&[urgent, boring], &context).as_deref(), Some("boring")); - 273
} - 274
- 275
#[test] - 276
fn higher_stakes_outrank_lower_all_else_equal() { - 277
let low = commitment("low", Stakes::Inert); - 278
let high = commitment("high", Stakes::Irreversible); - 279
assert_eq!(next(&[low, high], &context()).as_deref(), Some("high")); - 280
} - 281
- 282
#[test] - 283
fn suspended_blocked_and_closed_work_is_held_with_a_reason() { - 284
let mut suspended = commitment("s", Stakes::Costly); - 285
suspended.phase = Phase::Suspended; - 286
suspended.suspension = Some(crate::types::Suspension::Schedule { - 287
at: Some(chrono::Utc::now() + chrono::Duration::hours(4)), - 288
cron: None, - 289
}); - 290
- 291
let mut blocked = commitment("b", Stakes::Costly); - 292
blocked.phase = Phase::Blocked; - 293
blocked.blocker = Some("needs a database password".into()); - 294
- 295
let active = commitment("a", Stakes::Inert); - 296
- 297
let ranked = rank(&[suspended, blocked, active], &context()); - 298
assert_eq!(ranked[0].commitment_id, "a"); - 299
assert!(ranked[0].is_runnable()); - 300
for held in &ranked[1..] { - 301
assert!(!held.is_runnable()); - 302
assert!(held.withheld.as_ref().is_some_and(|r| !r.is_empty())); - 303
} - 304
} - 305
- 306
/// Budget exhaustion must hold the work, not destroy it: a human can raise - 307
/// the ceiling, and everything the commitment established is still good. - 308
#[test] - 309
fn an_over_budget_commitment_is_held_rather_than_closed() { - 310
let mut broke = commitment("broke", Stakes::Costly); - 311
broke.spec.economics.lifetime_budget_usd = Some(5.0); - 312
broke.spend_usd = 6.0; - 313
let priority = prioritize(&broke, &context()); - 314
assert!(!priority.is_runnable()); - 315
assert!(priority.withheld.as_ref().unwrap().contains("budget")); - 316
assert!(!broke.phase.is_terminal()); - 317
} - 318
- 319
#[test] - 320
fn a_stalled_commitment_stops_being_scheduled() { - 321
let mut stalled = commitment("stalled", Stakes::Costly); - 322
stalled.consecutive_stalls = 3; - 323
assert!(stalled.is_stalled()); - 324
assert!(!prioritize(&stalled, &context()).is_runnable()); - 325
} - 326
- 327
#[test] - 328
fn an_imminent_deadline_raises_priority() { - 329
let mut soon = commitment("soon", Stakes::Inert); - 330
soon.spec.economics.expires_at = Some(chrono::Utc::now() + chrono::Duration::minutes(30)); - 331
let later = commitment("later", Stakes::Inert); - 332
assert_eq!(next(&[later, soon], &context()).as_deref(), Some("soon")); - 333
} - 334
- 335
#[test] - 336
fn every_priority_explains_itself() { - 337
let priority = prioritize(&commitment("x", Stakes::Costly), &context()); - 338
assert!(!priority.components.is_empty()); - 339
assert!(priority.explain().contains("stakes")); - 340
} - 341
} - 342
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.