1use crate::catalog::CatalogEntry;
25use kernel::{AdmissionRequirement, AxCode, AxError, LandingPolicy, Mode, RunPolicy};
26use std::sync::{Arc, PoisonError, RwLock};
27
28pub const DEV_ENTRY: &str = "dev";
30
31#[must_use]
41pub fn dev_entry() -> CatalogEntry {
42 CatalogEntry {
43 name: DEV_ENTRY.to_owned(),
44 disclosure: "when the work is to change this city's own code, SPECs or tools, open this \
45 entry before you touch anything"
46 .to_owned(),
47 expansion: "This city is built from small components, each with its SPEC beside it at \
48 `crates/<crate>/<crate>-SPEC.md`. Read that SPEC, then the code, then the \
49 tests next to the code - in that order, and before you change any of them. \
50 Where the implementation would differ from the SPEC, the SPEC changes first \
51 and says why.\n\nThe User chooses what a change must prove before it is \
52 merged:\n- tested: the asset's own tests ran and passed.\n- contract_kept: \
53 the asset's observable contract did not move.\n- double_validated: the change \
54 holds on held-in and on held-out evidence.\nAnd where it lands: \
55 ordinary work takes the building's own road, while an experiment works in a \
56 tree of its own and nothing in it is merged.\n\nYour next step: say which \
57 evidence this work needs and why, and wait for the User to dispatch it \
58 with that requirement. Choosing a requirement does not provide the evidence."
59 .to_owned(),
60 hash: None,
63 package: None,
64 }
65}
66
67#[must_use]
71pub fn catalog_entry(mode: Mode) -> CatalogEntry {
72 let (disclosure, expansion) = match mode {
73 Mode::Chat => (
74 "chat mode: focus on conversing with the User; answer what they said, in their language",
75 "Reply in the conversation. Start work, plans or dispatches only when the User asks for them.",
76 ),
77 Mode::Work => (
78 "work mode: carry out the task towards the stated goal; report when the goal is met",
79 "When the task asks for a plan first, write it into Roadmap.md with the `plan` tool \
80 before you change anything, then work through it. Report what you did and the \
81 evidence that the goal is met.",
82 ),
83 };
84 CatalogEntry {
85 name: format!("mode:{}", mode.as_str()),
86 disclosure: disclosure.to_owned(),
87 expansion: expansion.to_owned(),
88 hash: None,
89 package: None,
90 }
91}
92
93#[must_use]
107pub fn core_tools(mode: Mode) -> &'static [&'static str] {
108 match mode {
109 Mode::Chat => &[
110 crate::tools::CallTool::NAME,
111 crate::tools::DescribeTool::NAME,
112 crate::tools::ReadTool::NAME,
113 crate::tools::SearchTool::NAME,
114 crate::tools::StatusTool::NAME,
115 ],
116 Mode::Work => &[
117 crate::tools::CallTool::NAME,
118 crate::tools::DescribeTool::NAME,
119 crate::tools::EditTool::NAME,
120 kernel::ToolName::EXEC,
121 crate::tools::ReadTool::NAME,
122 crate::tools::SearchTool::NAME,
123 crate::tools::StatusTool::NAME,
124 ],
125 }
126}
127
128#[must_use]
133pub fn in_some_core(name: &str) -> bool {
134 Mode::ALL
135 .iter()
136 .any(|mode| core_tools(*mode).contains(&name))
137}
138
139pub fn mode_admits(policy: &RunPolicy, name: &str) -> Result<(), AxError> {
148 if !in_some_core(name) || core_tools(policy.mode).contains(&name) {
149 return Ok(());
150 }
151 Err(AxError::failure(
152 AxCode::GateDenied,
153 format!("run {name}"),
154 format!("{name} does not run in {} mode", policy.mode.as_str()),
155 )
156 .with_recovery("answer in the conversation, or ask the User to switch this run to work mode"))
157}
158
159#[derive(Debug)]
169pub struct PolicyCell {
170 shared: Arc<RwLock<RunPolicy>>,
171 arrived: Option<RunPolicy>,
172}
173
174impl PolicyCell {
175 #[must_use]
177 pub fn new(start: RunPolicy) -> PolicyCell {
178 PolicyCell {
179 shared: Arc::new(RwLock::new(start)),
180 arrived: None,
181 }
182 }
183
184 #[must_use]
186 pub fn reader(&self) -> PolicyReader {
187 PolicyReader {
188 shared: Arc::clone(&self.shared),
189 }
190 }
191
192 pub(crate) fn arrive(&mut self, policy: RunPolicy) {
195 self.arrived = Some(policy);
196 }
197
198 pub(crate) fn take_at_wave(&mut self) -> Option<RunPolicy> {
202 let taken = self.arrived.take()?;
203 *self.shared.write().unwrap_or_else(PoisonError::into_inner) = taken;
206 Some(taken)
207 }
208}
209
210#[derive(Debug, Clone)]
213pub struct PolicyReader {
214 shared: Arc<RwLock<RunPolicy>>,
215}
216
217impl PolicyReader {
218 #[must_use]
220 pub fn now(&self) -> RunPolicy {
221 *self.shared.read().unwrap_or_else(PoisonError::into_inner)
222 }
223}
224
225#[must_use]
230pub(crate) fn policy_note(policy: &RunPolicy) -> String {
231 format!(
232 "The User changed this run's policy; from the next tool call on: mode {}, write {}, \
233 admission {}, landing {}.",
234 policy.mode.as_str(),
235 policy.write.as_str(),
236 policy.admit.as_str(),
237 policy.landing.as_str()
238 )
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub struct Produced {
249 pub tests_passed: Option<bool>,
251 pub contract_moved: bool,
253 pub held_in: Option<bool>,
255 pub held_out: Option<bool>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum Admission {
262 Lands,
263 Refused {
264 because: &'static str,
265 alternative: &'static str,
266 },
267}
268
269#[must_use]
277pub fn admits(policy: &RunPolicy, produced: &Produced) -> Admission {
278 match policy.landing {
279 LandingPolicy::Experiment => Admission::Refused {
280 because: "nothing produced in an experiment lands",
281 alternative: "write what you learned into Memo.md, then ask for the work again \
282 with the ordinary landing",
283 },
284 LandingPolicy::Ordinary => admits_evidence(policy.admit, produced),
285 }
286}
287
288fn admits_evidence(required: AdmissionRequirement, produced: &Produced) -> Admission {
298 match required {
299 AdmissionRequirement::Standing => Admission::Lands,
300 AdmissionRequirement::Tested => match produced.tests_passed {
301 Some(true) => Admission::Lands,
302 Some(false) => Admission::Refused {
303 because: "the asset's own tests did not pass",
304 alternative: "fix the asset or the test, then offer it again",
305 },
306 None => Admission::Refused {
307 because: "the asset has no tests of its own",
308 alternative: "write the test that would fail if this asset broke",
309 },
310 },
311 AdmissionRequirement::ContractKept => {
312 if produced.contract_moved {
313 Admission::Refused {
314 because: "the asset's observable contract moved",
315 alternative: "keep the contract and renovate behind it, or ask for the work \
316 again under double_validated with held-out evidence",
317 }
318 } else {
319 Admission::Lands
320 }
321 }
322 AdmissionRequirement::DoubleValidated => match (produced.held_in, produced.held_out) {
323 (Some(true), Some(true)) => Admission::Lands,
324 (Some(false), _) => Admission::Refused {
325 because: "it got worse on the held-in set",
326 alternative: "a change that costs more than it buys is not adopted; revise it",
327 },
328 (_, Some(false)) => Admission::Refused {
329 because: "it did not stand up on the held-out set",
330 alternative: "a gain that only shows where it was built is a gain in fitting, \
331 not in ability",
332 },
333 _ => Admission::Refused {
334 because: "one half of the double validation is missing",
335 alternative: "run the suite on both sets; an unmeasured change is not adopted on \
336 confidence",
337 },
338 },
339 }
340}
341
342#[cfg(test)]
343#[allow(
344 clippy::unwrap_used,
345 clippy::expect_used,
346 clippy::panic,
347 reason = "test code"
348)]
349mod tests {
350 use super::*;
351 use proptest::prelude::*;
352
353 #[derive(Debug, Clone, Copy)]
357 enum Step {
358 Change(RunPolicy),
359 Wave,
360 Call,
361 }
362
363 fn any_policy() -> impl Strategy<Value = RunPolicy> {
364 (
365 prop::sample::select(Mode::ALL.to_vec()),
366 prop::sample::select(kernel::WriteLimit::ALL.to_vec()),
367 prop::sample::select(AdmissionRequirement::ALL.to_vec()),
368 prop::sample::select(LandingPolicy::ALL.to_vec()),
369 )
370 .prop_map(|(mode, write, admit, landing)| RunPolicy {
371 mode,
372 write,
373 admit,
374 landing,
375 })
376 }
377
378 fn any_step() -> impl Strategy<Value = Step> {
379 prop_oneof![
380 any_policy().prop_map(Step::Change),
381 Just(Step::Wave),
382 Just(Step::Call),
383 ]
384 }
385
386 fn walk(start: RunPolicy, trace: &[Step]) -> Result<(), TestCaseError> {
390 let mut cell = PolicyCell::new(start);
391 let gate = cell.reader();
392 let mut in_force = start;
393 let mut last_arrival = None;
394 for step in trace {
395 match *step {
396 Step::Change(policy) => {
397 cell.arrive(policy);
398 last_arrival = Some(policy);
399 }
400 Step::Wave => {
401 let taken = cell.take_at_wave();
402 prop_assert_eq!(taken, last_arrival.take());
403 in_force = taken.unwrap_or(in_force);
404 }
405 Step::Call => prop_assert_eq!(gate.now(), in_force),
406 }
407 }
408 Ok(())
409 }
410
411 proptest! {
412 #[test]
413 fn policy_take_holds_on_every_trace(
414 start in any_policy(),
415 trace in prop::collection::vec(any_step(), 0..24),
416 ) {
417 walk(start, &trace)?;
418 }
419 }
420
421 #[test]
424 fn a_change_inside_a_wave_leaves_that_wave_alone() {
425 let start = RunPolicy::of(Mode::Work);
426 let tighter = RunPolicy {
427 write: kernel::WriteLimit::Create,
428 ..start
429 };
430 walk(start, &[Step::Call, Step::Change(tighter), Step::Call]).unwrap();
431 }
432
433 #[test]
436 fn a_chat_run_is_refused_an_edit_and_the_disk_is_untouched() {
437 let tmp = tempfile::tempdir().unwrap();
438 let work = kernel::Address::parse("work").unwrap();
439 std::fs::create_dir_all(tmp.path().join("work")).unwrap();
440 let tool = crate::tools::EditTool::new(
441 tmp.path(),
442 work.clone(),
443 kernel::WriteDomain::new(vec![work]).unwrap(),
444 crate::PolicyCell::new(kernel::RunPolicy::of(kernel::Mode::Chat)).reader(),
445 )
446 .unwrap();
447 let refused = kernel::Tool::invoke(&tool, &edit_call());
448 assert_eq!(
449 refused.err().map(|err| *err.code()),
450 Some(AxCode::GateDenied)
451 );
452 assert!(!tmp.path().join("work/new.md").exists());
453 }
454
455 fn edit_call() -> kernel::ToolCall {
456 let args = serde_json::json!({
457 "path": "work/new.md", "base_version": "new", "old": "", "new": "text",
458 });
459 kernel::ToolCall {
460 id: "call-1".to_owned(),
461 name: kernel::ToolName::parse("edit").unwrap(),
462 args: kernel::Payload::of(&args).unwrap(),
463 }
464 }
465
466 fn work(admit: AdmissionRequirement) -> RunPolicy {
467 RunPolicy {
468 admit,
469 ..RunPolicy::of(Mode::Work)
470 }
471 }
472
473 #[test]
476 fn a_work_run_without_the_evidence_it_chose_does_not_land() {
477 let Admission::Refused { because, .. } =
478 admits(&work(AdmissionRequirement::Tested), &Produced::default())
479 else {
480 panic!("work that chose `tested` and ran no test landed");
481 };
482 assert!(because.contains("no tests"), "{because}");
483 }
484
485 #[test]
487 fn chat_is_one_line_about_the_conversation() {
488 let entry = catalog_entry(Mode::Chat);
489 assert_eq!(entry.name, "mode:chat");
490 assert!(entry.disclosure.contains("convers"), "{}", entry.disclosure);
491 assert!(!entry.disclosure.contains('\n'), "one line");
492 assert!(
493 !entry.expansion.is_empty(),
494 "a read of the entry says something"
495 );
496 assert_eq!(catalog_entry(Mode::Work).name, "mode:work");
497 }
498
499 #[test]
502 fn standing_adds_nothing_in_either_mode() {
503 for mode in Mode::ALL {
504 assert_eq!(
505 admits(&RunPolicy::of(mode), &Produced::default()),
506 Admission::Lands
507 );
508 }
509 }
510
511 #[test]
512 fn tested_wants_the_asset_proven_and_says_which_way_it_failed() {
513 let policy = work(AdmissionRequirement::Tested);
514 let failed = Produced {
515 tests_passed: Some(false),
516 ..Produced::default()
517 };
518 let Admission::Refused { because, .. } = admits(&policy, &failed) else {
519 panic!("a failing asset does not land");
520 };
521 assert!(
522 because.contains("did not pass"),
523 "not the untested sentence"
524 );
525 let passed = Produced {
526 tests_passed: Some(true),
527 ..Produced::default()
528 };
529 assert_eq!(admits(&policy, &passed), Admission::Lands);
530 }
531
532 #[test]
533 fn contract_kept_refuses_a_renovation_that_moved_the_contract() {
534 let policy = work(AdmissionRequirement::ContractKept);
535 assert_eq!(admits(&policy, &Produced::default()), Admission::Lands);
536 let moved = Produced {
537 contract_moved: true,
538 ..Produced::default()
539 };
540 let Admission::Refused { alternative, .. } = admits(&policy, &moved) else {
541 panic!("a moved contract is not a renovation");
542 };
543 assert!(
544 alternative.contains("double_validated"),
545 "the refusal names the requirement that would take it"
546 );
547 }
548
549 #[test]
550 fn double_validated_takes_nothing_on_confidence() {
551 let policy = work(AdmissionRequirement::DoubleValidated);
552 let both = Produced {
553 held_in: Some(true),
554 held_out: Some(true),
555 ..Produced::default()
556 };
557 assert_eq!(admits(&policy, &both), Admission::Lands);
558 for missing in [
559 Produced {
560 held_in: Some(true),
561 ..Produced::default()
562 },
563 Produced {
564 held_out: Some(true),
565 ..Produced::default()
566 },
567 Produced::default(),
568 ] {
569 assert!(
570 matches!(admits(&policy, &missing), Admission::Refused { .. }),
571 "half of a double validation is not a double validation"
572 );
573 }
574 let regressed = Produced {
575 held_in: Some(true),
576 held_out: Some(false),
577 ..Produced::default()
578 };
579 let Admission::Refused { because, .. } = admits(&policy, ®ressed) else {
580 panic!("a change that only holds where it was built does not land");
581 };
582 assert!(because.contains("held-out"));
583 }
584
585 #[test]
588 fn an_experiment_lands_nothing_however_well_it_went() {
589 let excellent = Produced {
590 tests_passed: Some(true),
591 contract_moved: false,
592 held_in: Some(true),
593 held_out: Some(true),
594 };
595 for admit in AdmissionRequirement::ALL {
596 let policy = RunPolicy {
597 landing: LandingPolicy::Experiment,
598 write: kernel::WriteLimit::Create,
599 ..work(admit)
600 };
601 assert!(matches!(
602 admits(&policy, &excellent),
603 Admission::Refused { .. }
604 ));
605 }
606 }
607}