1//! A shell command, before it runs. 2//! 3//! Jev is asked three questions in one request: which of eight acts the 4//! command is ([`Act`]), how hard it would be to undo, and how much of the 5//! machine it takes. Five facts are read out of those three answers, each by 6//! a threshold below. Two more are not Jev's: how much memory there is room 7//! for, which the daemon measures when a rule tells it to, and whether the 8//! command has waited long enough for room, which the daemon knows. 9//! [`RULES`] turn the seven into a verdict. 10 11use jev_facts::{Judged, Prepared, Source, Unlearned}; 12use jev_protocol::{Choice, Json, ProtocolError, Question, Response, Score}; 13use jevhooks_events::Verdict; 14use rete::{Domain, Known, Network, Next, Rule, Test, Then}; 15use serde_json::json; 16 17use crate::{Never, SHOWN_CHARS, head, percent}; 18 19/// A command is put to the user when Jev's probability of one particular 20/// consequential act reaches this. One act, not their sum: a thin spread over 21/// several is Jev being unsure, which is a `Pass`, not a flag. 22pub const ASK_AT_CONSEQUENTIAL: f64 = 0.60; 23/// ... or when its expected undo level reaches this (1 is "one ordinary 24/// command puts it back", 2 is "only with care or luck"). 25pub const ASK_AT_UNDO: f64 = 1.60; 26/// A command runs without asking when Jev's probability that it is one of 27/// the ordinary acts reaches this, and its expected undo level is at most 28/// [`ALLOW_UNDO_AT_MOST`]. 29pub const ALLOW_AT_ORDINARY: f64 = 0.90; 30pub const ALLOW_UNDO_AT_MOST: f64 = 1.20; 31/// The memory a command of each load level should find available before it 32/// starts, in megabytes, lowest level first. 33pub const LOAD_NEEDS_MB: [f64; 4] = [0.0, 500.0, 3000.0, 6000.0]; 34 35/// What "the command does" means: 36/// without it Jev judges dangerous words wherever they appear, and a command 37/// that only writes, prints or sends `rm -rf` as text is stopped as if it 38/// ran it. Sent once per request, as the state's `how_to_judge_the_command`. 39const WHAT_RUNS: &str = "Judge only what the shell would execute when this command line runs. Text that is \ 40 merely carried as data is not executed: the body of a here-document, or a quoted string, that is written to \ 41 a file, printed, searched for, compared, or sent as the content of a request. Such text is executed only \ 42 when it is handed to something that runs it: sh, bash, eval, source, xargs, ssh, or an interpreter such as \ 43 python or node. Writing a script to a file does not run the script."; 44 45/// The most consequential thing a shell command does. Each variant's 46/// definition is the text Jev chooses by. 47#[derive(Clone, Copy, Debug, PartialEq, Eq)] 48pub enum Act { 49 Read, 50 Build, 51 Edit, 52 Delete, 53 History, 54 System, 55 Remote, 56 Unread, 57} 58 59impl Act { 60 pub const ALL: [Act; 8] = 61 [Act::Read, Act::Build, Act::Edit, Act::Delete, Act::History, Act::System, Act::Remote, Act::Unread]; 62 63 /// The option's label, as Jev is given it and answers with it. 64 pub fn label(self) -> &'static str { 65 match self { 66 Act::Read => "read", 67 Act::Build => "build", 68 Act::Edit => "edit", 69 Act::Delete => "delete", 70 Act::History => "history", 71 Act::System => "system", 72 Act::Remote => "remote", 73 Act::Unread => "unread", 74 } 75 } 76 77 /// What the option means, in acts. 78 pub fn definition(self) -> &'static str { 79 match self { 80 Act::Read => { 81 "It only reads or prints: listing, searching, showing, measuring, asking a program or a \ 82 service for its status, counters or settings. Nothing on disk or anywhere else is different \ 83 afterwards. It is still this when the reading is done through another shell or on another \ 84 machine (ssh, PowerShell, a remote exec wrapper, an API request that only fetches)." 85 } 86 Act::Build => { 87 "It builds, tests, formats, lints or installs a project's dependencies: it creates, replaces \ 88 or removes generated files (build output, caches, lockfiles, formatted source) that running \ 89 the build again would produce. Also this: creating, overwriting or removing scratch files \ 90 and directories under a temporary location (/tmp, a scratchpad, a temp folder) that the \ 91 command itself makes or that exist only to be thrown away." 92 } 93 Act::Edit => { 94 "It creates or changes files a person wrote or will keep (source, notes, configuration inside \ 95 a repository), in any directory, or records them in version control (add, commit, a new \ 96 branch, a stash). The earlier content can be recovered with an ordinary version-control or \ 97 editor command." 98 } 99 Act::Delete => { 100 "It removes or overwrites files or data that someone keeps, that no build regenerates and \ 101 version control does not hold: rm of untracked or personal files, truncating a kept file \ 102 with a redirect, emptying a directory of kept files, dropping a database or its rows." 103 } 104 Act::History => { 105 "It discards or rewrites version-control state: reset --hard, checkout or restore over \ 106 uncommitted work, clean, rebase, amending pushed commits, a force push, deleting a branch \ 107 or a stash." 108 } 109 Act::System => { 110 "It changes how the machine or the user's account is set up: installing, removing or \ 111 upgrading system packages, switching the system to a new configuration, starting, stopping \ 112 or enabling services, changing permissions and ownership, editing shell profiles and \ 113 dotfiles in the home directory, writing the registry or system settings, killing processes, \ 114 anything that changes something with sudo." 115 } 116 Act::Remote => { 117 "It publishes or sends something to another machine or service: push, deploy, publish, \ 118 release, an API call that writes, sending a message." 119 } 120 Act::Unread => { 121 "It runs code nobody has read: a script piped from the network into a shell, eval of fetched \ 122 content, an installer run straight from a URL." 123 } 124 } 125 } 126 127 /// The phrase the user reads. 128 pub fn phrase(self) -> &'static str { 129 match self { 130 Act::Read => "only reads", 131 Act::Build => "builds or tests", 132 Act::Edit => "edits files in a way version control can undo", 133 Act::Delete => "deletes or overwrites data nothing regenerates", 134 Act::History => "discards or rewrites version-control state", 135 Act::System => "changes the machine or the account", 136 Act::Remote => "publishes or sends something elsewhere", 137 Act::Unread => "runs code nobody has read", 138 } 139 } 140 141 /// Acts worth stopping for. The rest are a developer's ordinary work. 142 pub fn is_consequential(self) -> bool { 143 matches!(self, Act::Delete | Act::History | Act::System | Act::Remote | Act::Unread) 144 } 145 146 fn from_label(label: &str) -> Option<Act> { 147 Act::ALL.into_iter().find(|act| act.label() == label) 148 } 149} 150 151/// How hard a command is to put back, lowest first: the Score's levels. 152const UNDO_LEVELS: [&str; 4] = [ 153 "Nothing to put back: the command changes nothing.", 154 "One ordinary command puts it back: deleting a new file, git checkout or revert, running a build again.", 155 "It can be put back only with care or luck: digging through the reflog, restoring a backup, re-creating \ 156 work by hand, reinstalling.", 157 "It cannot be put back from this machine: the data is gone, or it has been published or sent somewhere else.", 158]; 159 160/// The same levels as the user reads them. 161const UNDO_PHRASES: [&str; 4] = ["nothing to undo", "undone by one command", "hard to undo", "cannot be undone"]; 162 163/// How much of the machine a command takes while it runs, lowest first: the 164/// load Score's levels. 165const LOAD_LEVELS: [&str; 4] = [ 166 "Negligible: it finishes at once and uses almost no memory. Listing, reading, git bookkeeping, moving a file.", 167 "Light: one small program for a moment. A formatter, a linter on a few files, a short script, one small test.", 168 "Heavy: it compiles a project, runs a whole test suite, builds a container or a package, or starts a \ 169 browser or another AI coding session. Several processor cores and gigabytes of memory for a while.", 170 "Very heavy: several heavy jobs at once, an optimised release build of a large project, a build of many \ 171 packages, or anything that holds many gigabytes of memory.", 172]; 173 174/// The same levels as the user reads them. 175const LOAD_PHRASES: [&str; 4] = ["a negligible", "a light", "a heavy", "a very heavy"]; 176 177/// The domain: a marker for the engine, and the source of the questions. 178#[derive(Clone, Copy, Debug, PartialEq, Eq)] 179pub struct Command; 180 181/// Something known about a command. 182#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] 183pub enum Fact { 184 /// One consequential act is at least [`ASK_AT_CONSEQUENTIAL`] likely. 185 Consequential, 186 /// The expected undo level is at least [`ASK_AT_UNDO`]. 187 HardToUndo, 188 /// The ordinary acts together are at least [`ALLOW_AT_ORDINARY`] likely. 189 Ordinary, 190 /// The expected undo level is at most [`ALLOW_UNDO_AT_MOST`]. 191 EasyToUndo, 192 /// How much of the machine it takes: the level Jev's score rounds to. 193 Load, 194 /// Whether there is memory for it now. Not Jev's: [`Effect::Measure`] 195 /// teaches it. 196 Room, 197 /// Whether it can still wait for room. Not Jev's: the daemon knows how 198 /// long the command has waited, and says so before the rules run. 199 Patience, 200} 201 202/// What a fact can turn out to be. 203#[derive(Clone, Copy, Debug, PartialEq, Eq)] 204pub enum Value { 205 Yes, 206 No, 207 /// [`Fact::Load`]'s four levels. 208 Negligible, 209 Light, 210 Heavy, 211 VeryHeavy, 212 /// [`Fact::Room`]: there is memory for what the command needs. 213 Enough, 214 /// There is less than it needs. 215 Short, 216 /// Memory cannot be measured on this machine, so the load is not judged. 217 Unmeasured, 218 /// [`Fact::Patience`]: it may wait longer. 219 Left, 220 /// It has waited as long as a command waits, or cannot be kept waiting. 221 Spent, 222} 223 224const YES_NO: [Value; 2] = [Value::Yes, Value::No]; 225const LOADS: [Value; 4] = [Value::Negligible, Value::Light, Value::Heavy, Value::VeryHeavy]; 226 227fn yes(holds: bool) -> Value { 228 if holds { Value::Yes } else { Value::No } 229} 230 231/// The one thing a rule sends the daemon to do. 232#[derive(Clone, Copy, Debug, PartialEq, Eq)] 233pub enum Effect { 234 /// See how much memory there is room for: what the machine reports 235 /// available, less what is booked for commands still growing into theirs. 236 Measure, 237} 238 239impl Domain for Command { 240 type Fact = Fact; 241 type Value = Value; 242 type Effect = Effect; 243 type End = Verdict; 244 type Note = Never; 245 246 fn facts() -> &'static [Fact] { 247 &[Fact::Consequential, Fact::HardToUndo, Fact::Ordinary, Fact::EasyToUndo, Fact::Load, Fact::Room, Fact::Patience] 248 } 249 250 // Short, because a drawing has a narrow box for a name and its value. 251 fn fact_name(fact: Fact) -> &'static str { 252 match fact { 253 Fact::Consequential => "consequential", 254 Fact::HardToUndo => "hard to undo", 255 Fact::Ordinary => "ordinary work", 256 Fact::EasyToUndo => "easy to undo", 257 Fact::Load => "load", 258 Fact::Room => "room", 259 Fact::Patience => "patience", 260 } 261 } 262 263 fn values(fact: Fact) -> &'static [Value] { 264 match fact { 265 Fact::Consequential | Fact::HardToUndo | Fact::Ordinary | Fact::EasyToUndo => &YES_NO, 266 Fact::Load => &LOADS, 267 Fact::Room => &[Value::Enough, Value::Short, Value::Unmeasured], 268 Fact::Patience => &[Value::Left, Value::Spent], 269 } 270 } 271 272 fn value_name(value: Value) -> &'static str { 273 match value { 274 Value::Yes => "yes", 275 Value::No => "no", 276 Value::Negligible => "negligible", 277 Value::Light => "light", 278 Value::Heavy => "heavy", 279 Value::VeryHeavy => "very heavy", 280 Value::Enough => "enough", 281 Value::Short => "short", 282 Value::Unmeasured => "unmeasured", 283 Value::Left => "left", 284 Value::Spent => "spent", 285 } 286 } 287 288 fn asked_for(fact: Fact) -> bool { 289 !matches!(fact, Fact::Room | Fact::Patience) 290 } 291 292 fn teaches(effect: Effect) -> Fact { 293 match effect { 294 Effect::Measure => Fact::Room, 295 } 296 } 297 298 fn effect_name(effect: Effect) -> String { 299 match effect { 300 Effect::Measure => "measure".to_owned(), 301 } 302 } 303 304 fn end_name(end: Verdict) -> String { 305 verdict_name(end).to_owned() 306 } 307 308 fn note_name(note: Never) -> String { 309 match note {} 310 } 311} 312 313/// A verdict's name, as the mod and the log spell it. 314pub fn verdict_name(verdict: Verdict) -> &'static str { 315 match verdict { 316 Verdict::Allow => "allow", 317 Verdict::Ask => "ask", 318 Verdict::Pass => "pass", 319 Verdict::Hold => "hold", 320 } 321} 322 323/// The rules, in priority order: of the rules that hold, the first decides. 324/// 325/// Measuring comes first so that a command put to the user for what it does 326/// is put with both reasons when memory is short as well. The two rules on 327/// what it does come before the two on room, so such a command is never kept 328/// waiting. A command with no room is put to the user unless it is known to 329/// have patience left: not knowing is not a reason to wait. The last two 330/// are every answer the rule before them does not allow, each named for 331/// what is true of it, so an answer no other rule acts on is a `Pass`, and 332/// the user's own permission rules decide. 333pub const RULES: [Rule<'static, Command>; 8] = [ 334 Rule { name: "look for room", when: &[Test::Known(Fact::Load)], then: Then::Do(Effect::Measure) }, 335 Rule { name: "a consequential act", when: &[Test::Is(Fact::Consequential, Value::Yes)], then: Then::End(Verdict::Ask) }, 336 Rule { name: "hard to put back", when: &[Test::Is(Fact::HardToUndo, Value::Yes)], then: Then::End(Verdict::Ask) }, 337 Rule { 338 name: "no room yet", 339 when: &[Test::Is(Fact::Room, Value::Short), Test::Is(Fact::Patience, Value::Left)], 340 then: Then::End(Verdict::Hold), 341 }, 342 Rule { name: "no room", when: &[Test::Is(Fact::Room, Value::Short)], then: Then::End(Verdict::Ask) }, 343 Rule { 344 name: "ordinary and easily undone", 345 when: &[Test::Is(Fact::Ordinary, Value::Yes), Test::Is(Fact::EasyToUndo, Value::Yes)], 346 then: Then::End(Verdict::Allow), 347 }, 348 Rule { name: "not surely ordinary", when: &[Test::Is(Fact::Ordinary, Value::No)], then: Then::End(Verdict::Pass) }, 349 Rule { name: "not easily undone", when: &[Test::Is(Fact::EasyToUndo, Value::No)], then: Then::End(Verdict::Pass) }, 350]; 351 352/// [`RULES`] as the network that runs. 353pub fn network() -> Network<Command> { 354 Network::compile(&RULES) 355} 356 357/// What Jev judges: the command, where it runs, and how to read it. 358/// 359/// What "the command does" means is said once, here, where all three 360/// questions read it. It used to end each question; asked both ways, ten 361/// commands got the same verdicts and the request was 212 tokens smaller 362/// (1,555 to 1,343; the daemon's live test, 2026-10-06). 363pub fn state(command: &str, cwd: Option<&str>, root: Option<&str>) -> Result<Json, ProtocolError> { 364 let state = json!({ 365 "command": head(command, SHOWN_CHARS), 366 "working_directory": cwd, 367 "project_root": root, 368 "how_to_judge_the_command": WHAT_RUNS, 369 }); 370 Json::verbatim(&state.to_string()) 371} 372 373/// The probability of each act, from the `act` Choice's answer. 374fn acts(answer: &jev_protocol::ChoiceAnswer) -> Vec<(Act, f64)> { 375 answer.probabilities.iter().filter_map(|(label, p)| Act::from_label(label).map(|act| (act, *p))).collect() 376} 377 378/// The likeliest single consequential act's probability. One act, never 379/// their sum. 380fn consequential(acts: &[(Act, f64)]) -> f64 { 381 acts.iter().filter(|(act, _)| act.is_consequential()).map(|(_, p)| *p).fold(0.0, f64::max) 382} 383 384/// The ordinary acts' probabilities together. 385fn ordinary(acts: &[(Act, f64)]) -> f64 { 386 acts.iter().filter(|(act, _)| !act.is_consequential()).map(|(_, p)| p).sum() 387} 388 389impl Source for Command { 390 type Fact = Fact; 391 type Value = Value; 392 393 fn question_id(&self, fact: Fact) -> String { 394 match fact { 395 Fact::Consequential | Fact::Ordinary => "act", 396 Fact::HardToUndo | Fact::EasyToUndo => "undo", 397 Fact::Load => "load", 398 Fact::Room => "room", 399 Fact::Patience => "patience", 400 } 401 .to_owned() 402 } 403 404 fn question(&self, fact: Fact) -> Result<Question, ProtocolError> { 405 Ok(match fact { 406 Fact::Consequential | Fact::Ordinary => Question::Choice(Choice::new( 407 Json::text( 408 "A coding assistant is about to run this shell command for a developer. Which one of these \ 409 describes what the command does? If it does several of them, pick the one that is hardest to \ 410 undo.", 411 ), 412 Act::ALL.map(|act| (act.label().to_owned(), Some(Json::text(act.definition())))), 413 )?), 414 Fact::HardToUndo | Fact::EasyToUndo => Question::Score(Score::new( 415 Json::text("How hard would it be to put everything back exactly as it was before this command ran?"), 416 UNDO_LEVELS.map(Json::text), 417 )?), 418 Fact::Load => Question::Score(Score::new( 419 Json::text("How much of the machine does this command take while it runs?"), 420 LOAD_LEVELS.map(Json::text), 421 )?), 422 Fact::Room | Fact::Patience => { 423 return Err(ProtocolError::Invalid(format!("{} is not a fact Jev is asked for", Command::fact_name(fact)))); 424 } 425 }) 426 } 427 428 fn learned(&self, fact: Fact, judged: &Judged) -> Option<Value> { 429 Some(match (fact, judged) { 430 (Fact::Consequential, Judged::Choice(answer)) => yes(consequential(&acts(answer)) >= ASK_AT_CONSEQUENTIAL), 431 (Fact::Ordinary, Judged::Choice(answer)) => yes(ordinary(&acts(answer)) >= ALLOW_AT_ORDINARY), 432 (Fact::HardToUndo, Judged::Score(answer)) => yes(answer.score >= ASK_AT_UNDO), 433 (Fact::EasyToUndo, Judged::Score(answer)) => yes(answer.score <= ALLOW_UNDO_AT_MOST), 434 (Fact::Load, Judged::Score(answer)) => LOADS[(answer.score.round() as usize).min(LOADS.len() - 1)], 435 _ => return None, 436 }) 437 } 438} 439 440/// Jev's numbers about a command, beside the facts they teach: what the line 441/// the user reads is written from, what the memory it needs is worked out 442/// from, and what the decision log keeps. 443#[derive(Clone, Debug)] 444pub struct Answers { 445 /// The probability of each act. 446 pub acts: Vec<(Act, f64)>, 447 /// The expected undo level, 0 to 3. 448 pub undo: f64, 449 /// The expected load level, 0 to 3. 450 pub load: f64, 451 json: serde_json::Value, 452} 453 454impl Answers { 455 /// From a request's answers; `None` unless all three questions were in it. 456 fn read(prepared: &Prepared, judged: &[Judged]) -> Option<Self> { 457 let answer = |id: &str| prepared.parts.iter().position(|part| part.id == id).and_then(|index| judged.get(index)); 458 let (Judged::Choice(act), Judged::Score(undo), Judged::Score(load)) = (answer("act")?, answer("undo")?, answer("load")?) else { 459 return None; 460 }; 461 Some(Answers { 462 acts: acts(act), 463 undo: undo.score, 464 load: load.score, 465 json: json!({ 466 "act": { 467 "choice": act.choice, 468 "confidence": act.confidence, 469 "probabilities": act.probabilities.iter().map(|(label, p)| (label.clone(), json!(p))).collect::<serde_json::Map<String, serde_json::Value>>(), 470 }, 471 "undo": { "score": undo.score, "probabilities": undo.probabilities }, 472 "load": { "score": load.score, "probabilities": load.probabilities }, 473 }), 474 }) 475 } 476 477 /// The megabytes this command should find free before it starts. 478 pub fn needs_mb(&self) -> f64 { 479 needed_mb(self.load) 480 } 481 482 /// The likeliest single consequential act's probability: the number 483 /// behind [`Fact::Consequential`]. 484 pub fn consequential(&self) -> f64 { 485 consequential(&self.acts) 486 } 487 488 /// The ordinary acts' probabilities together: the number behind 489 /// [`Fact::Ordinary`]. 490 pub fn ordinary(&self) -> f64 { 491 ordinary(&self.acts) 492 } 493 494 /// Every option's probability, as the decision log keeps it. 495 pub fn json(&self) -> serde_json::Value { 496 self.json.clone() 497 } 498} 499 500/// What Jev said about a command: the facts, and the numbers behind them. 501/// Kept apart from the verdict because the verdict also depends on how much 502/// memory there is room for, and that changes while a command waits its turn. 503#[derive(Clone, Debug)] 504pub struct Asked { 505 pub known: Known<Command>, 506 pub answers: Answers, 507} 508 509/// The facts the first step asks Jev for: every fact of the domain that is 510/// Jev's to give, since every rule is waiting on one. 511pub fn wanted(network: &Network<Command>) -> Vec<Fact> { 512 match network.next(&Known::default()) { 513 Next::Ask(facts) => facts, 514 // The rules always begin by asking; `the_first_step_asks_everything` holds that. 515 _ => Vec::new(), 516 } 517} 518 519/// What a response to `prepared` (a request for `facts`) teaches. 520pub fn taught(prepared: &Prepared, response: &Response, facts: &[Fact]) -> Result<Asked, Unlearned> { 521 let judged = prepared.judged(response); 522 let mut known = Known::default(); 523 for (fact, value) in jev_facts::learn(&Command, prepared, &judged, facts)? { 524 known.learn(fact, value); 525 } 526 let answers = Answers::read(prepared, &judged).ok_or(Unlearned::NotAsked { id: "act, undo and load".to_owned() })?; 527 Ok(Asked { known, answers }) 528} 529 530/// The memory a command of expected load level `load` should find 531/// available, interpolated between the levels' needs. 532pub fn needed_mb(load: f64) -> f64 { 533 let load = load.clamp(0.0, (LOAD_NEEDS_MB.len() - 1) as f64); 534 let below = load.floor() as usize; 535 let above = (below + 1).min(LOAD_NEEDS_MB.len() - 1); 536 LOAD_NEEDS_MB[below] + (LOAD_NEEDS_MB[above] - LOAD_NEEDS_MB[below]) * (load - below as f64) 537} 538 539/// What [`Effect::Measure`] teaches: whether `room_mb` megabytes are enough 540/// for a command that needs `needs_mb`. No figure is no opinion. 541pub fn room(needs_mb: f64, room_mb: Option<f64>) -> Value { 542 match room_mb { 543 None => Value::Unmeasured, 544 Some(room) if room < needs_mb => Value::Short, 545 Some(_) => Value::Enough, 546 } 547} 548 549/// [`Fact::Patience`], from whether the command may be kept waiting longer. 550pub fn patience(may_wait: bool) -> Value { 551 if may_wait { Value::Left } else { Value::Spent } 552} 553 554impl Asked { 555 /// Whether the command is put to the user whatever memory there is. 556 pub fn is_risky(&self) -> bool { 557 [Fact::Consequential, Fact::HardToUndo].into_iter().any(|fact| self.known.get(fact) == Some(Value::Yes)) 558 } 559 560 /// The verdict, with `room_mb` megabytes to spare (absent where memory 561 /// cannot be measured) and whether the command may be kept waiting, and 562 /// the line the user reads. The network decides; when it says to 563 /// measure, the figure given is the measurement. 564 pub fn settle(&self, network: &Network<Command>, room_mb: Option<f64>, may_wait: bool) -> (Verdict, String) { 565 let mut known = self.known.clone(); 566 known.forget(Fact::Room); 567 known.learn(Fact::Patience, patience(may_wait)); 568 let verdict = loop { 569 match network.next(&known) { 570 Next::Do(Effect::Measure) => known.learn(Fact::Room, room(self.answers.needs_mb(), room_mb)), 571 Next::End(verdict) => break verdict, 572 // Nothing Jev did not answer can be asked from here, and 573 // not knowing never blocks. 574 Next::Ask(_) | Next::Done => break Verdict::Pass, 575 } 576 }; 577 (verdict, self.line(&known, room_mb, verdict)) 578 } 579 580 /// What Jev thought and how sure it was, as the permission prompt and 581 /// the band show it. 582 fn line(&self, known: &Known<Command>, room_mb: Option<f64>, verdict: Verdict) -> String { 583 let Answers { acts, undo, load, .. } = &self.answers; 584 let (likeliest, likelihood) = acts.iter().copied().max_by(|a, b| a.1.total_cmp(&b.1)).unwrap_or((Act::Read, 0.0)); 585 let level = (undo.round() as usize).min(UNDO_PHRASES.len() - 1); 586 let said = format!("Jev: {} ({}%); {} ({undo:.1} of 3)", likeliest.phrase(), percent(likelihood), UNDO_PHRASES[level]); 587 // Too little memory for what the command is about to start. 588 let short = room_mb.filter(|_| known.get(Fact::Room) == Some(Value::Short)).map(|room| { 589 let load_level = (load.round() as usize).min(LOAD_PHRASES.len() - 1); 590 format!("{} command ({load:.1} of 3) with {:.1} GB of memory to spare", LOAD_PHRASES[load_level], room / 1024.0) 591 }); 592 match (verdict, self.is_risky(), short) { 593 (Verdict::Ask, true, Some(short)) => format!("{said}; {short}"), 594 (Verdict::Ask, true, None) => said, 595 (Verdict::Ask | Verdict::Hold, false, Some(short)) => format!("Jev: {short}"), 596 (Verdict::Allow, ..) => format!("{said}; allowed"), 597 _ => format!("{said}; left to the usual permission check"), 598 } 599 } 600 601 /// Answers made up for a test or a worked example, read the way a real 602 /// response is: a response body with these numbers, parsed and learned 603 /// from. 604 #[cfg(feature = "made-up")] 605 pub fn made_up(acts: &[(Act, f64)], undo: f64, load: f64) -> Self { 606 let network = network(); 607 let facts = wanted(&network); 608 let model = crate::jev_model().expect("the pinned model"); 609 let prepared = jev_facts::wanted(&Command, &model, state("a command", None, None).expect("a state"), &facts).expect("a request"); 610 let probabilities: Vec<(&str, f64)> = 611 Act::ALL.iter().map(|each| (each.label(), acts.iter().find(|(act, _)| act == each).map_or(0.0, |(_, p)| *p))).collect(); 612 let body = crate::made_up_body(&[ 613 ("act", crate::made_up_choice(&probabilities)), 614 ("undo", crate::made_up_score(undo, UNDO_LEVELS.len())), 615 ("load", crate::made_up_score(load, LOAD_LEVELS.len())), 616 ]); 617 let response = Response::parse(&model, prepared.asking().1, body.as_bytes()).expect("a response Jev could send"); 618 taught(&prepared, &response, &facts).expect("every fact asked for") 619 } 620 621 /// Made up: Jev sure of one act. 622 #[cfg(feature = "made-up")] 623 pub fn sure_of(act: Act, undo: f64, load: f64) -> Self { 624 Self::made_up(&[(act, 1.0)], undo, load) 625 } 626} 627 628#[cfg(test)] 629mod tests { 630 use super::*; 631 632 /// The verdict on made-up answers with this much memory to spare and nobody to wait for. 633 fn verdict(acts: &[(Act, f64)], undo: f64, load: f64, room_mb: Option<f64>) -> (Verdict, String) { 634 Asked::made_up(acts, undo, load).settle(&network(), room_mb, false) 635 } 636 637 fn surely(act: Act) -> Vec<(Act, f64)> { 638 vec![(act, 1.0)] 639 } 640 641 /// Every fact Jev can give goes in the one first request, and the three questions behind 642 /// the five facts are each asked once. 643 #[test] 644 fn the_first_step_asks_everything() { 645 let facts = wanted(&network()); 646 assert_eq!(facts, [Fact::Consequential, Fact::HardToUndo, Fact::Ordinary, Fact::EasyToUndo, Fact::Load]); 647 let prepared = jev_facts::wanted(&Command, &crate::jev_model().unwrap(), state("ls", None, None).unwrap(), &facts).unwrap(); 648 let ids: Vec<&str> = prepared.parts.iter().map(|part| part.id.as_str()).collect(); 649 assert_eq!(ids, ["act", "undo", "load"]); 650 // What "the command does" means is in the state, once. 651 assert_eq!(prepared.request.matches("Judge only what the shell would execute").count(), 1); 652 } 653 654 /// Reading, building and editing, undone by one command at most, run without a prompt: this is 655 /// the plugin's whole benefit on an ordinary day. 656 #[test] 657 fn ordinary_work_is_allowed() { 658 assert_eq!(verdict(&surely(Act::Read), 0.0, 1.0, Some(8000.0)).0, Verdict::Allow); 659 assert_eq!(verdict(&surely(Act::Build), 1.0, 1.0, Some(8000.0)).0, Verdict::Allow); 660 // A commit, or a note written in another repository. 661 assert_eq!(verdict(&surely(Act::Edit), 1.0, 1.0, Some(8000.0)).0, Verdict::Allow); 662 } 663 664 /// Each of the five consequential acts stops for the user, even when it would be easy to undo. 665 #[test] 666 fn consequential_acts_are_put_to_the_user() { 667 for act in [Act::Delete, Act::History, Act::System, Act::Remote, Act::Unread] { 668 assert_eq!(verdict(&surely(act), 1.0, 1.0, Some(8000.0)).0, Verdict::Ask, "{act:?}"); 669 } 670 } 671 672 /// The undo score is a second, independent reason to ask: a harmless-sounding act that cannot 673 /// be put back still stops. 674 #[test] 675 fn anything_hard_to_undo_is_put_to_the_user_whatever_it_is_called() { 676 assert_eq!(verdict(&surely(Act::Edit), 2.0, 1.0, Some(8000.0)).0, Verdict::Ask); 677 assert_eq!(verdict(&surely(Act::Build), 1.6, 1.0, Some(8000.0)).0, Verdict::Ask); 678 } 679 680 /// Jev not knowing is a `Pass`: the user's own permission rules decide, as if the plugin were 681 /// not there. 682 #[test] 683 fn an_unsure_answer_is_neither_allowed_nor_asked() { 684 let split = [(Act::Edit, 0.6), (Act::Delete, 0.4)]; 685 assert_eq!(verdict(&split, 1.3, 1.0, Some(8000.0)).0, Verdict::Pass); 686 } 687 688 /// The bug this fixed: summing a thin spread over several consequential acts stopped a command 689 /// Jev was merely unsure about. 690 #[test] 691 fn doubt_spread_over_several_consequential_acts_is_not_a_flag() { 692 let spread = [(Act::Unread, 0.36), (Act::System, 0.2), (Act::Delete, 0.14), (Act::Edit, 0.3)]; 693 assert_eq!(verdict(&spread, 1.3, 1.0, Some(8000.0)).0, Verdict::Pass); 694 } 695 696 /// With no room and nobody to wait for, a heavy command is put to the user, and the line says 697 /// what it needs and what there is. No figure means no opinion. 698 #[test] 699 fn a_heavy_command_is_put_to_the_user_when_memory_is_short() { 700 // A build with 1.5 GB available: a heavy command wants 3 GB. 701 let (verdict_, line) = verdict(&surely(Act::Build), 1.0, 2.0, Some(1500.0)); 702 assert_eq!(verdict_, Verdict::Ask); 703 assert_eq!(line, "Jev: a heavy command (2.0 of 3) with 1.5 GB of memory to spare"); 704 // The same build with room to spare runs. 705 assert_eq!(verdict(&surely(Act::Build), 1.0, 2.0, Some(8000.0)).0, Verdict::Allow); 706 // A light command is not held up by the same shortage. 707 assert_eq!(verdict(&surely(Act::Edit), 1.0, 0.3, Some(1500.0)).0, Verdict::Allow); 708 // Where memory cannot be measured, load is not judged. 709 assert_eq!(verdict(&surely(Act::Build), 1.0, 3.0, None).0, Verdict::Allow); 710 } 711 712 /// The same shortage with patience left is a wait, not a question: and a command that would be 713 /// put to the user anyway is never kept waiting, and is put with both reasons. 714 #[test] 715 fn a_command_with_no_room_waits_unless_it_is_risky_or_has_waited() { 716 let build = Asked::sure_of(Act::Build, 1.0, 2.0); 717 assert_eq!(build.settle(&network(), Some(1500.0), true).0, Verdict::Hold); 718 assert_eq!(build.settle(&network(), Some(1500.0), false).0, Verdict::Ask); 719 let push = Asked::sure_of(Act::History, 2.3, 2.0); 720 let (verdict, line) = push.settle(&network(), Some(1500.0), true); 721 assert_eq!(verdict, Verdict::Ask); 722 assert_eq!( 723 line, 724 "Jev: discards or rewrites version-control state (100%); hard to undo (2.3 of 3); a heavy command (2.0 of 3) with 1.5 GB of memory to spare" 725 ); 726 } 727 728 /// A command with no room and nothing known about its patience is put to the user: only a 729 /// command known to have patience left waits. 730 #[test] 731 fn not_knowing_whether_it_may_wait_is_not_a_reason_to_wait() { 732 let mut known = Asked::sure_of(Act::Build, 1.0, 2.0).known; 733 known.learn(Fact::Room, Value::Short); 734 assert_eq!(network().next(&known), Next::End(Verdict::Ask)); 735 } 736 737 /// Room is measured before anything ends, whatever Jev said: the rule that measures is first. 738 #[test] 739 fn the_room_is_measured_before_a_verdict() { 740 for asked in [Asked::sure_of(Act::Read, 0.0, 0.0), Asked::sure_of(Act::History, 3.0, 3.0)] { 741 assert_eq!(network().next(&asked.known), Next::Do(Effect::Measure)); 742 } 743 } 744 745 /// A load between two levels needs memory between theirs, so a score of 2.5 is not treated as a 746 /// 2 or a 3. 747 #[test] 748 fn need_rises_between_levels() { 749 assert_eq!(needed_mb(0.0), 0.0); 750 assert_eq!(needed_mb(2.0), 3000.0); 751 assert_eq!(needed_mb(2.5), 4500.0); 752 assert_eq!(needed_mb(9.0), 6000.0); 753 } 754 755 /// The line is what the user reads in the permission prompt and the band: it must say what Jev 756 /// thought and how sure it was. 757 #[test] 758 fn the_line_names_the_likeliest_act_and_the_undo_level() { 759 let (_, line) = verdict(&surely(Act::History), 2.3, 1.0, Some(8000.0)); 760 assert_eq!(line, "Jev: discards or rewrites version-control state (100%); hard to undo (2.3 of 3)"); 761 } 762 763 /// A label is what Jev answers with and what is matched back; two acts sharing one would 764 /// silently merge their probabilities. 765 #[test] 766 fn every_act_has_its_own_label() { 767 for act in Act::ALL { 768 assert_eq!(Act::from_label(act.label()), Some(act)); 769 } 770 } 771 772 /// A rule lights up in the drawing when it holds, so no rule may hold for an answer it is not 773 /// about: for ordinary, easily undone work with room, the rule that allows is the only one 774 /// that ends it. (The catch-all this replaced held for every answer.) 775 #[test] 776 fn only_the_rule_that_applies_holds() { 777 let mut known = Asked::sure_of(Act::Build, 1.0, 1.0).known; 778 known.learn(Fact::Room, Value::Enough); 779 known.learn(Fact::Patience, Value::Left); 780 assert_eq!(network().holding(&known), ["look for room", "ordinary and easily undone"]); 781 } 782 783 /// The rules name only values their facts can have, and every rule has a name of its own: what 784 /// a loader would refuse in a file is refused here for the rules in code. 785 #[test] 786 fn every_rule_tests_a_value_its_fact_can_have() { 787 for rule in RULES { 788 for test in rule.when { 789 if let Test::Is(fact, value) = test { 790 assert!(Command::values(*fact).contains(value), "{}: {fact:?} cannot be {value:?}", rule.name); 791 } 792 } 793 assert_eq!(RULES.iter().filter(|other| other.name == rule.name).count(), 1, "{}", rule.name); 794 } 795 } 796}