1use std::io::{Read, Write};
11use std::path::PathBuf;
12
13use borg_trace::fingerprint::bash_read_paths;
14use serde_json::{Value, json};
15
16use crate::harness::{self, Harness};
17use crate::learn::learning;
18use crate::{collective, conscience, learn, live, outcome, predict, prefilter};
19
20pub(crate) fn state_file(session: &str) -> PathBuf {
21 let dir = std::env::var_os("BORG_STATE").map_or_else(|| PathBuf::from("/tmp/borg"), PathBuf::from);
22 let _ = std::fs::create_dir_all(&dir);
23 let safe: String = session.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
24 dir.join(format!("{safe}.ctx"))
25}
26
27pub(crate) fn append(session: &str, text: &str) {
30 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session)) {
31 let _ = f.write_all(format!("{text}\n").as_bytes());
32 }
33}
34
35pub(crate) fn touched(input: &Value, tool: &str) -> String {
37 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
38 match tool {
39 "Bash" => {
40 let cmd = s("command");
41 let paths = bash_read_paths(&cmd);
42 if paths.is_empty() { cmd.chars().take(COMMAND_CHARS).collect() } else { paths.join(" ") }
43 }
44 "Grep" => format!("{} {}", s("pattern"), s("path")),
45 "Glob" => s("pattern"),
46 _ => {
47 let mut t = [s("file_path"), s("notebook_path"), s("path")].join(" ").trim().to_string();
48 for p in further_files(input) {
50 if !t.is_empty() {
51 t.push(' ');
52 }
53 t.push_str(p);
54 }
55 t
56 }
57 }
58}
59
60const COMMAND_CHARS: usize = borg_trace::dialogue::TOUCH_CHARS;
64
65pub(crate) fn further_files(input: &Value) -> impl Iterator<Item = &str> {
69 input.get("edits").and_then(Value::as_array).into_iter().flatten().filter_map(|e| e.get("file_path").and_then(Value::as_str)).filter(|p| !p.is_empty())
70}
71
72pub(crate) fn repo_of(cwd: &str) -> String {
73 borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
74}
75
76fn stat(session: &str, input: &Value, kind: &str, fill: impl FnOnce(&mut crate::stats::Event)) {
78 let home = learn::home();
79 let mut ev = crate::stats::Event::new(kind, learn::now_ms());
80 ev.session = session.into();
81 ev.repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
82 ev.engineer = crate::presence::read(&home, session).map(|p| p.engineer).unwrap_or_default();
84 fill(&mut ev);
85 crate::stats::record(&home, &ev);
86}
87
88fn prompts_file(session: &str) -> PathBuf {
90 state_file(session).with_extension("prompts")
91}
92
93fn where_file(session: &str) -> PathBuf {
97 state_file(session).with_extension("where")
98}
99
100fn where_now(session: &str) -> String {
103 let paths = std::fs::read_to_string(where_file(session)).unwrap_or_default();
104 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
105 format!("{paths}\n{latest}")
106}
107
108fn note_where(session: &str, text: &str) {
109 if text.trim().is_empty() {
110 return;
111 }
112 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(where_file(session)) {
113 let _ = writeln!(f, "{text}");
114 }
115}
116
117fn task_file(session: &str) -> PathBuf {
118 state_file(session).with_extension("task")
119}
120
121fn denied_file(session: &str) -> PathBuf {
125 state_file(session).with_extension("denied")
126}
127
128fn capture_prompt(h: &dyn Harness, session: &str, input: &Value, prompt: &str) {
131 if !learning() {
132 return;
133 }
134 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
135 if let Ok(tool) = std::fs::read_to_string(denied_file(session)) {
136 let _ = std::fs::remove_file(denied_file(session));
137 let said = prompt.trim();
138 if said.len() > 3 && !said.starts_with('<') {
139 record(session, input, task, proposal(h, input), Some(tool), said.chars().take(learn::EXCERPT_CHARS).collect());
140 return;
141 }
142 }
143 if !learn::is_correction(prompt) || task.is_empty() {
144 let task: String = prompt.chars().take(learn::TASK_CHARS).collect();
145 let _ = std::fs::write(task_file(session), task);
146 return;
147 }
148 record(session, input, task, proposal(h, input), None, prompt.chars().take(learn::EXCERPT_CHARS).collect());
149}
150
151fn veto(h: &dyn Harness, session: &str, input: &Value, reason: &str) {
154 let _ = std::fs::remove_file(denied_file(session));
155 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
156 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
157 let said = crate::veto::Said {
158 session,
159 repo: &repo_of(cwd),
160 engineer: &learn::engineer(cwd),
161 task: &task,
162 agent: &proposal(h, input),
163 reason,
164 ms: learn::now_ms(),
165 via: "prompt",
166 };
167 if let Ok(v) = crate::veto::record(&learn::home(), &said) {
168 append(session, &format!("#borg-episode {}", v.episode));
169 }
170}
171
172fn capture_rejection(h: &dyn Harness, session: &str, input: &Value) {
175 if !learning() {
176 return;
177 }
178 let error = input.get("error").and_then(Value::as_str).unwrap_or_default();
179 let tool = format!(
180 "{} {}",
181 input.get("tool_name").and_then(Value::as_str).unwrap_or_default(),
182 input.get("tool_input").map(|v| v.to_string()).unwrap_or_default().chars().take(learn::EXCERPT_CHARS).collect::<String>()
183 );
184 let Some(said) = learn::rejection(error) else {
185 if learn::denied(error) {
186 let _ = std::fs::write(denied_file(session), tool);
187 }
188 return;
189 };
190 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
191 record(session, input, task, proposal(h, input), Some(tool), said);
192}
193
194fn proposal(h: &dyn Harness, input: &Value) -> String {
197 let t = full_proposal(h, input);
198 t.chars().rev().take(learn::PROPOSAL_CHARS).collect::<Vec<_>>().into_iter().rev().collect()
199}
200
201fn full_proposal(h: &dyn Harness, input: &Value) -> String {
204 harness::last_message(h.caps(), input)
205}
206
207fn record(session: &str, input: &Value, task: String, agent: String, rejected_tool: Option<String>, correction: String) {
208 let ms = learn::now_ms();
209 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
210 let e = learn::Episode {
211 id: format!("{}-{ms}", session.chars().take(8).collect::<String>()),
212 ms,
213 repo: repo_of(cwd),
214 session: session.into(),
215 task,
216 agent,
217 rejected_tool,
218 correction,
219 engineer: learn::engineer(cwd),
220 ..learn::Episode::default()
221 };
222 let _ = learn::record_episode(&e);
223 append(session, &format!("#borg-episode {}", e.id));
224}
225
226fn lessons(session: &str) -> Vec<conscience::Challenge> {
231 loaded(session).0
232}
233
234fn judged(all: &[conscience::Challenge], withheld: Vec<conscience::Challenge>) -> std::borrow::Cow<'_, [conscience::Challenge]> {
238 if withheld.is_empty() { all.into() } else { all.iter().cloned().chain(withheld).collect::<Vec<_>>().into() }
239}
240
241fn loaded(session: &str) -> (Vec<conscience::Challenge>, Vec<conscience::Challenge>) {
243 let before = std::env::var("BORG_BEFORE").ok().and_then(|v| v.parse().ok());
244 let mut all = conscience::load_store_for(&lessons_path(), before, Some(session));
246 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
249 let repos = crate::systems::work_repos(&where_text);
250 if crate::systems::on() {
251 crate::systems::gate(&mut all, &repos);
252 }
253 all.retain(|c| crate::reach::deliverable(c, &repos));
255 let withheld = crate::absorb::withhold_beside(&lessons_path(), &mut all, before);
257 if crate::concepts::on() {
260 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
261 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
262 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
263 crate::concepts::apply(&mut all, &crate::concepts::of_work(&edits, &where_text, &latest), crate::concepts::beta());
264 }
265 (all, withheld)
266}
267
268fn lessons_path() -> PathBuf {
270 std::env::var_os("BORG_CONSCIENCE").map(PathBuf::from).unwrap_or_else(|| learn::home().join("lessons.jsonl"))
271}
272
273fn edits_file(session: &str) -> PathBuf {
278 state_file(session).with_extension("edits")
279}
280
281fn cost_file(session: &str) -> PathBuf {
285 state_file(session).with_extension("cost")
286}
287
288fn changes_file(session: &str) -> PathBuf {
291 state_file(session).with_extension("changes")
292}
293
294fn log_cost(session: &str, line: &str) {
295 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(cost_file(session)) {
296 let _ = writeln!(f, "{line}");
297 }
298}
299
300fn early_acted(session: &str, all: &[conscience::Challenge], final_msg: &str) -> std::collections::HashSet<String> {
307 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
308 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
309 let lines: Vec<&str> = edits.lines().filter(|l| !l.starts_with(WORK)).collect();
310 let mut out = std::collections::HashSet::new();
311 for (p, l) in lines.iter().enumerate() {
312 let Some(list) = l.strip_prefix("#borg-early ").or_else(|| l.strip_prefix("#borg-queued ")) else { continue };
313 let ids: std::collections::HashSet<&str> = ids_in(list).collect();
314 let asked: Vec<&conscience::Challenge> = all.iter().filter(|c| ids.contains(c.id.as_str())).collect();
315 let after: Vec<&str> = lines[p + 1..].iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
316 let upto = state.find(&format!("{l}\n")).map_or(state.as_str(), |i| &state[..i]);
317 let before = format!("{upto}\n{}", lines[..p].join("\n"));
318 out.extend(crate::memory::resolve(&asked, all, &before, &after.join("\n"), final_msg, crate::memory::NOVEL_WORDS).0);
319 }
320 out
321}
322
323fn done_in<'a>(state: &'a str, acted: &std::collections::HashSet<String>) -> std::collections::HashSet<&'a str> {
328 let mut done = listed(state, "#borg-asked ");
329 done.extend(listed(state, "#borg-early ").into_iter().chain(listed(state, "#borg-queued ")).filter(|i| acted.contains(*i)));
330 done
331}
332
333fn had_in(state: &str) -> std::collections::HashSet<&str> {
336 let mut had = listed(state, "#borg-asked ");
337 had.extend(listed(state, "#borg-early "));
338 had.extend(listed(state, "#borg-queued "));
339 had
340}
341
342const WORK: &str = "#borg-work ";
347
348pub(crate) fn edit_text(tool: &str, input: &Value) -> Option<String> {
351 if !["edit", "write", "multiedit", "notebookedit"].contains(&tool.to_ascii_lowercase().as_str()) {
352 return None;
353 }
354 let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
355 let mut text = [s(input, "file_path"), s(input, "notebook_path")].join(" ");
356 for p in further_files(input) {
357 text.push(' ');
358 text.push_str(p);
359 }
360 for k in ["new_string", "content", "new_source"] {
361 text.push(' ');
362 text.push_str(&s(input, k));
363 }
364 for e in input.get("edits").and_then(Value::as_array).into_iter().flatten() {
365 text.push(' ');
366 text.push_str(&s(e, "new_string"));
367 }
368 Some(text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(EDIT_CHARS).collect())
369}
370
371const EDIT_CHARS: usize = 600;
374
375const EVIDENCE_CHARS: usize = 4000;
381const _: () = assert!(EVIDENCE_CHARS >= 3 * predict::JUDGE_PART);
382
383pub(crate) fn one_line(text: &str, n: usize) -> String {
385 text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(n).collect()
386}
387
388fn note_answer(h: &dyn Harness, session: &str, input: &Value) {
392 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
393 let lines: Vec<&str> = edits.lines().collect();
394 let Some(i) = lines.iter().rposition(|l| l.starts_with("#borg-asked ")) else { return };
395 if lines[i + 1..].iter().any(|l| l.starts_with("#borg-answer ")) {
396 return;
397 }
398 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
399 let _ = writeln!(f, "#borg-answer {}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
400 }
401}
402
403const MAX_PENDING: usize = conscience::CAP;
409const MAX_JUDGE_TRIES: usize = 2;
415
416pub(crate) const END_START_BUDGET: usize = 72_000;
424
425const END_LESSONS_SHARE: usize = 2;
429
430#[derive(Debug, Clone, Copy, PartialEq)]
432pub(crate) struct EndBudget {
433 pub chars: usize,
435 pub draw: Option<(f64, bool)>,
438}
439
440pub(crate) fn end_budget(state: &std::path::Path, model: &str, seed: &str) -> EndBudget {
446 if let Some(pinned) = std::env::var("BORG_END_BUDGET").ok().and_then(|v| v.trim().parse::<usize>().ok()).filter(|n| *n > 0) {
447 return EndBudget { chars: pinned, draw: None };
448 }
449 let len = crate::judge_length::length(state, model);
450 let mut h = blake3::Hasher::new();
451 h.update(b"borg-end-explore-v1\0");
452 h.update(seed.as_bytes());
453 let bytes: [u8; 8] = h.finalize().as_bytes()[..8].try_into().unwrap_or_default();
454 let u = crate::memory::Rng::new(u64::from_le_bytes(bytes)).unit();
455 match len.explore {
456 Some(up) if u < len.psi => EndBudget { chars: up, draw: Some((len.psi, true)) },
457 _ => EndBudget { chars: len.chars, draw: Some((len.psi, false)) },
458 }
459}
460
461pub(crate) fn judge_model(spec: &str) -> String {
464 crate::runner::choose(Some(spec)).first().map(crate::runner::Choice::label).unwrap_or_default()
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475enum Cut {
476 Newest,
477 After,
478 Ends,
479}
480
481impl Cut {
482 fn from_env() -> Cut {
484 match std::env::var("BORG_END_WORK").unwrap_or_default().trim().to_ascii_lowercase().as_str() {
485 "newest" => Cut::Newest,
486 "after" => Cut::After,
487 _ => Cut::Ends,
488 }
489 }
490
491 fn word(self) -> &'static str {
493 match self {
494 Cut::Newest => "newest",
495 Cut::After => "after",
496 Cut::Ends => "ends",
497 }
498 }
499
500 fn window(self, text: &[char], n: usize, at: Option<usize>) -> String {
504 let all = text.len();
505 let s = |r: std::ops::Range<usize>| text[r].iter().collect::<String>();
506 let mark = |cut: bool| if cut { "…" } else { "" };
507 let at = at.map(|a| a.min(all));
508 match (self, at) {
509 (Cut::After, Some(a)) => {
510 let start = a.min(all - n);
511 format!("{}{}{}", mark(start > 0), s(start..start + n), mark(start + n < all))
512 }
513 (Cut::Ends, Some(a)) if a + n / 2 < all - (n - n / 2) => format!("{}{}…{}", mark(a > 0), s(a..a + n / 2), s(all - (n - n / 2)..all)),
514 (Cut::Ends, Some(_)) => Cut::After.window(text, n, at),
515 _ => format!("…{}", s(all - n..all)),
516 }
517 }
518}
519
520fn end_timeout() -> std::time::Duration {
526 learn::learn_timeout().min(predict::timeout().saturating_mul(2))
527}
528
529const END_JUDGED: &str = "#borg-end-judged ";
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538enum When {
539 Stop,
540 End,
541}
542
543const DELIVERED: [&str; 4] = ["#borg-asked ", "#borg-early ", "#borg-queued ", "#borg-placebo "];
548
549const ROUTED: &str = "#borg-routed ";
553
554fn is_marker(line: &str) -> bool {
557 DELIVERED.iter().any(|m| line.starts_with(m)) || line.starts_with("#borg-live ") || line.starts_with(ROUTED)
558}
559
560#[must_use]
565pub fn stripped(check: &str, lesson: &str) -> bool {
566 let mut h = blake3::Hasher::new();
567 h.update(b"borg-strip-v1\0");
568 h.update(format!("{check}\0{lesson}").as_bytes());
569 h.finalize().as_bytes()[0] & 1 == 1
570}
571
572fn verdicts_in(state: &str) -> (std::collections::HashMap<&str, usize>, std::collections::HashSet<&str>) {
576 let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
577 let mut settled = std::collections::HashSet::new();
578 for l in state.lines() {
579 if let Some(l) = l.strip_prefix("#borg-judged ") {
580 let (id, label) = l.split_once('=').unwrap_or((l, ""));
581 *tries.entry(id).or_default() += 1;
582 if label.trim() != "pending" {
583 settled.insert(id);
584 }
585 } else if let Some(list) = l.strip_prefix(END_JUDGED) {
586 settled.extend(ids_in(list));
587 } else if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
588 for id in ids_in(list) {
589 tries.remove(id);
590 settled.remove(id);
591 }
592 }
593 }
594 (tries, settled)
595}
596
597fn open_in(state: &str) -> std::collections::HashSet<&str> {
600 let (_, settled) = verdicts_in(state);
601 let mut open: std::collections::HashSet<&str> = DELIVERED.iter().flat_map(|m| listed(state, m)).collect();
602 open.retain(|id| !settled.contains(id));
603 open
604}
605
606struct Judging<'a> {
609 pending: Vec<predict::Pending<'a>>,
610 question: bool,
611 crowded: Vec<String>,
612}
613
614fn pending<'a>(session: &str, state: &str, all: &'a [conscience::Challenge], final_msg: &str, when: When, judging: &str) -> Judging<'a> {
632 let (tries, settled) = verdicts_in(state);
633 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
634 let lines: Vec<&str> = edits.lines().collect();
635 let part = predict::JUDGE_PART;
636 struct Delivery<'l> {
638 channel: &'static str,
639 question: bool,
641 before: &'l str,
642 answers: Vec<&'l str>,
643 work: Vec<&'l str>,
644 fresh: Vec<(&'l str, &'l str, bool)>,
645 }
646 let mut deliveries: Vec<Delivery> = Vec::new();
647 for (p, l) in lines.iter().enumerate() {
648 let Some((marker, list)) = DELIVERED.iter().find_map(|m| l.strip_prefix(m).map(|rest| (*m, rest))) else { continue };
649 let question = marker == "#borg-asked ";
650 let later = &lines[p + 1..];
651 let channel = match marker {
652 "#borg-asked " if later.first().is_some_and(|n| n.starts_with("#borg-live ")) => "live",
653 "#borg-asked " => "stop",
654 "#borg-early " => "early",
655 "#borg-queued " => "queued",
656 _ => outcome::PLACEBO,
657 };
658 let answers: Vec<&str> = if question { later.iter().filter_map(|l| l.strip_prefix("#borg-answer ")).collect() } else { Vec::new() };
659 let work: Vec<&str> = later.iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
660 let (list, tag) = tagged(list);
661 let before = match lines[..p].iter().rev().find(|b| !is_marker(b)) {
664 Some(b) if b.starts_with(WORK) => &b[WORK.len()..],
665 Some(b) if !b.starts_with("#borg-") => b,
666 _ => "",
667 };
668 let routed: std::collections::HashMap<&str, &str> =
669 later.first().and_then(|n| n.strip_prefix(ROUTED)).into_iter().flat_map(|r| r.split(',')).filter_map(|it| it.split_once('=')).collect();
670 let fresh: Vec<&str> = list
671 .split(',')
672 .map(str::trim)
673 .filter(|i| !i.is_empty() && !settled.contains(i) && (when == When::End || tries.get(i).copied().unwrap_or(0) < MAX_JUDGE_TRIES))
674 .filter(|i| all.iter().any(|c| c.id == *i))
675 .collect();
676 let check_of = |id: &str| routed.get(id).copied().or(tag).unwrap_or_default();
677 let fresh =
678 fresh.into_iter().map(|id| (id, check_of(id), question && stripped(if check_of(id).is_empty() { judging } else { check_of(id) }, id))).collect();
679 deliveries.push(Delivery { channel, question, before, answers, work, fresh });
680 }
681 let strip: Vec<&str> = deliveries.iter().flat_map(|d| &d.fresh).filter(|f| f.2).map(|f| f.0).collect();
684 let blind = |text: &str| strip.iter().fold(text.to_string(), |t, id| crate::memory::strip_declaration(&t, id));
685 let turn = predict::clip(&one_line(&blind(final_msg), part + 1), part);
686 let mut out: Vec<(predict::Pending, bool)> = Vec::new();
688 for d in deliveries {
689 let answer = blind(&d.answers.join(" "));
691 let said = answer.to_lowercase();
692 let since: Vec<&str> = std::iter::once(answer.as_str()).filter(|a| !a.is_empty()).chain(d.work.iter().copied()).collect();
693 let after = format!("work since: {}\nthis turn: {turn}", predict::clip(&blind(&since.join(" | ")), 2 * part));
694 let before = blind(d.before);
695 for (id, check, stripped) in d.fresh {
696 let Some(c) = all.iter().find(|c| c.id == id) else { continue };
697 out.retain(|x| x.0.lesson.id != id);
699 let declared = if stripped { None } else { crate::memory::declared(&said, &id.to_lowercase()) };
700 let pending =
701 predict::Pending { lesson: c, before: before.clone(), after: after.clone(), declared, check: check.to_string(), channel: d.channel, stripped };
702 out.push((pending, d.question));
703 }
704 }
705 let slots = if when == When::End { usize::MAX } else { MAX_PENDING };
706 let questions = out.iter().filter(|x| x.1).count().min(slots);
707 let mut room = [slots - questions, questions];
708 let (mut kept, mut crowded): (Vec<(predict::Pending, bool)>, Vec<String>) = (Vec::new(), Vec::new());
709 for x in out.into_iter().rev() {
710 let r = &mut room[usize::from(x.1)];
711 if *r > 0 {
712 *r -= 1;
713 kept.push(x);
714 } else {
715 crowded.push(x.0.lesson.id.clone());
716 }
717 }
718 kept.reverse();
719 crowded.sort();
720 Judging { pending: kept.into_iter().map(|x| x.0).collect(), question: questions > 0, crowded }
721}
722
723fn without_declarations<'t>(text: &'t str, pending: &[predict::Pending]) -> std::borrow::Cow<'t, str> {
726 pending.iter().filter(|p| p.stripped).fold(std::borrow::Cow::Borrowed(text), |t, p| crate::memory::strip_declaration(&t, &p.lesson.id).into())
727}
728
729fn note_unjudged(session: &str, ids: &[String], why: &str) {
733 if !ids.is_empty() {
734 append(session, &format!("#borg-unjudged {} why={why}", ids.join(",")));
735 }
736}
737
738pub(crate) fn unjudged(state: &str) -> std::collections::HashMap<String, &'static str> {
743 let mut why: std::collections::HashMap<String, &'static str> = std::collections::HashMap::new();
744 let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
745 for l in state.lines() {
746 if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
747 for id in ids_in(list) {
748 tries.remove(id);
749 why.insert(id.to_string(), outcome::UNJUDGED_NO_CALL);
750 }
751 } else if let Some(l) = l.strip_prefix("#borg-judged ") {
752 let (id, label) = l.split_once('=').unwrap_or((l, ""));
753 if label.trim() == "pending" {
754 let n = tries.entry(id).or_default();
755 *n += 1;
756 if *n >= MAX_JUDGE_TRIES {
757 why.insert(id.to_string(), outcome::UNJUDGED_TRIES);
758 }
759 }
760 } else if let Some(list) = l.strip_prefix(END_JUDGED) {
761 for id in ids_in(list) {
763 tries.insert(id, MAX_JUDGE_TRIES);
764 why.insert(id.to_string(), outcome::UNJUDGED_TRIES);
765 }
766 } else if let Some(rest) = l.strip_prefix("#borg-unjudged ") {
767 let (list, reason) = rest.rsplit_once(" why=").unwrap_or((rest, ""));
768 let reason = match reason.trim() {
769 "slots" => outcome::UNJUDGED_SLOTS,
770 "failed" => outcome::UNJUDGED_FAILED,
771 _ => continue,
772 };
773 for id in list.split(',').map(str::trim).filter(|i| !i.is_empty()) {
774 if tries.get(id).copied().unwrap_or(0) < MAX_JUDGE_TRIES {
775 why.insert(id.to_string(), reason);
776 }
777 }
778 }
779 }
780 why
781}
782
783fn record_verdicts(session: &str, repo: &str, at: &str, pending: &[predict::Pending], pred: &predict::Prediction, logged: &Logged) {
798 type Key<'k> = (&'k str, &'k str, &'k str, bool);
801 let mut records: std::collections::BTreeMap<Key, Vec<(String, String)>> = std::collections::BTreeMap::new();
802 for p in pending {
803 let judged = pred.resolved.iter().find(|j| j.id == p.lesson.id);
804 let word = match judged {
807 Some(j) if j.label().is_some() && (j.verdict != predict::Verdict::NotArisen || at == outcome::AT_END) => j.verdict.word(),
808 _ => "pending",
809 };
810 append(session, &format!("#borg-judged {}={word}", p.lesson.id));
811 let Some(j) = judged else { continue };
812 records.entry((j.verdict.kind(), p.channel, p.check.as_str(), p.stripped)).or_default().push((p.lesson.id.clone(), j.reason.clone()));
813 }
814 let at_field = if at.is_empty() { String::new() } else { format!(" at={at}") };
815 let line = judge_line(logged.call, pending, &pred.resolved, logged.chars, &pred.returned);
816 log_cost(session, &format!("{line} model={}{at_field}{}", logged.model, logged.fields));
817 if learning() {
818 let (home, ms) = (learn::home(), learn::now_ms());
819 let mut counted: std::collections::BTreeMap<(&str, &str), Vec<String>> = std::collections::BTreeMap::new();
821 for ((kind, channel, check, stripped), judged) in records {
822 let (lessons, reasons): (Vec<String>, Vec<String>) = judged.into_iter().unzip();
823 counted.entry((kind, channel)).or_default().extend(lessons.iter().cloned());
824 let o = outcome::Outcome {
825 kind: kind.into(),
826 session: session.into(),
827 lessons,
828 ms,
829 repo: repo.into(),
830 channel: channel.into(),
831 check: check.into(),
832 judge: predict::JUDGE_VERSION,
833 stripped,
834 at: at.into(),
835 reasons: if reasons.iter().all(String::is_empty) { Vec::new() } else { reasons },
837 ..Default::default()
838 };
839 let _ = learn::append_jsonl(&home.join("outcomes.jsonl"), &o);
840 }
841 for ((kind, channel), lessons) in counted {
842 outcome::count_result(
843 &home,
844 &outcome::Outcome { kind: kind.into(), session: session.into(), lessons, ms, repo: repo.into(), channel: channel.into(), ..Default::default() },
845 );
846 }
847 }
848}
849
850struct Logged {
855 call: usize,
856 chars: usize,
857 model: String,
858 fields: String,
859}
860
861fn judge_line(call: usize, pending: &[predict::Pending], resolved: &[predict::Judged], chars: usize, returned: &[String]) -> String {
869 let unmatched: Vec<&str> = returned.iter().map(String::as_str).filter(|id| !pending.iter().any(|p| p.lesson.id == *id)).collect();
870 let mine: Vec<&predict::Judged> = resolved.iter().filter(|j| pending.iter().any(|p| p.lesson.id == j.id)).collect();
871 let settled = mine.iter().filter(|j| j.label().is_some()).count();
872 let verdicts: Vec<String> = predict::Verdict::ALL.iter().map(|v| format!("{}:{}", v.word(), mine.iter().filter(|j| j.verdict == *v).count())).collect();
873 let reason_chars: usize = mine.iter().map(|j| j.reason.chars().count()).sum();
874 format!(
875 "judge call={call} pending={} resolved={settled} chars={chars} returned={} unmatched={} verdicts={} reason_chars={reason_chars}",
876 pending.len(),
877 returned.join(","),
878 unmatched.join(","),
879 verdicts.join(",")
880 )
881}
882
883pub fn judge_session(session: &str) -> usize {
906 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
907 if !learning() {
908 return 0;
909 }
910 let ctx = state_file(session);
911 let opened = |state: &str| state.lines().any(|l| DELIVERED.iter().any(|m| l.starts_with(m)));
912 if !opened(&std::fs::read_to_string(&ctx).unwrap_or_default()) {
913 return 0;
914 }
915 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
916 if crate::runner::choose(Some(&model)).is_empty() {
917 return 0;
918 }
919 let wait = u64::try_from(live::stale_ms()).unwrap_or(u64::MAX);
923 while live::wait_free(&ctx, wait) == live::Lock::Held {}
924 let Some(guard) = live::acquire(&ctx, learn::now_ms()) else { return 0 };
925 let guard = std::sync::Mutex::new(guard);
926 let state = std::fs::read_to_string(&ctx).unwrap_or_default();
927 let (all, withheld) = loaded(session);
928 let judged = judged(&all, withheld);
929 let last = crate::notes::proposal(&crate::notes::state_dir(), session);
930 let check = crate::select::check_id(session, "end", learn::now_ms(), std::process::id());
931 let judging = pending(session, &state, &judged, &last, When::End, &check);
932 let pend = &judging.pending;
933 if pend.is_empty() {
934 return 0;
935 }
936 let label = judge_model(&model);
937 let budget = end_budget(ctx.parent().unwrap_or(std::path::Path::new(".")), &label, &check);
939 let cut = Cut::from_env();
940 let sources = session_sources(session, &state, pend, budget.chars);
941 let groups = batches(pend, budget.chars);
942 let (next, failures, answers, lost) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicBool::new(false));
943 let repo = where_repo(session);
944 let n = groups.len();
945 let draw = budget.draw.map_or(String::new(), |(psi, explored)| format!(" psi={psi:.4} explore={}", u8::from(explored)));
946 let mut call = state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count();
949 let mut done = 0;
950 let mut asked = vec![false; n];
951 let (tx, rx) = std::sync::mpsc::channel::<(usize, Batch)>();
952 std::thread::scope(|s| {
953 for _ in 0..crate::runner::JOBS.min(n) {
954 let tx = tx.clone();
955 let (groups, next, failures, answers, lost, guard, sources, model) = (&groups, &next, &failures, &answers, &lost, &guard, &sources, &model);
956 s.spawn(move || {
957 while !lost.load(Ordering::SeqCst) && (answers.load(Ordering::SeqCst) > 0 || failures.load(Ordering::SeqCst) < crate::runner::MAX_FAILURES) {
958 let i = next.fetch_add(1, Ordering::SeqCst);
959 let Some(range) = groups.get(i) else { break };
960 if guard.lock().map_or(true, |mut g| g.renew(learn::now_ms()) == live::Renewal::Lost) {
962 lost.store(true, Ordering::SeqCst);
963 break;
964 }
965 let b = ask_batch(&pend[range.clone()], sources, budget.chars, cut, model);
966 (if b.pred.is_some() { answers } else { failures }).fetch_add(1, Ordering::SeqCst);
967 if tx.send((i, b)).is_err() {
968 break;
969 }
970 }
971 });
972 }
973 drop(tx);
974 for (i, b) in rx {
976 asked[i] = true;
977 let batch = &pend[groups[i].clone()];
978 let ids: Vec<String> = batch.iter().map(|p| p.lesson.id.clone()).collect();
979 crate::stats::count(&learn::home(), crate::stats::MODEL_CALL, session, |ev| {
980 ev.why = "judge".into();
981 ev.repo = repo.clone();
982 ev.n = batch.len() as u64;
983 ev.usd = b.pred.as_ref().map_or(0.0, |p| p.cost);
984 ev.dur_ms = b.ms;
985 if b.pred.is_none() {
986 ev.text = "no answer".into();
987 }
988 });
989 let fields = format!(" batch={}/{n} budget={}{draw} cut={}", i + 1, budget.chars, if b.cut { cut.word() } else { "none" });
990 let Some(pred) = b.pred else {
991 log_cost(session, &format!("judge-failed pending={} chars={} model={label} at={}{fields}", batch.len(), b.chars, outcome::AT_END));
993 note_unjudged(session, &ids, "failed");
994 continue;
995 };
996 call += 1;
997 record_verdicts(session, &repo, outcome::AT_END, batch, &pred, &Logged { call, chars: b.chars, model: label.clone(), fields });
998 append(session, &format!("{END_JUDGED}{}", ids.join(",")));
999 done += ids.len();
1000 }
1001 });
1002 if !lost.load(Ordering::SeqCst) {
1004 let unasked: Vec<String> =
1005 groups.iter().zip(&asked).filter(|(_, a)| !**a).flat_map(|(r, _)| pend[r.clone()].iter().map(|p| p.lesson.id.clone())).collect();
1006 note_unjudged(session, &unasked, "failed");
1007 }
1008 done
1009}
1010
1011fn batches(pending: &[predict::Pending], budget: usize) -> Vec<std::ops::Range<usize>> {
1017 let room = budget / END_LESSONS_SHARE;
1018 let mut out = Vec::new();
1019 let mut start = 0;
1020 for end in 2..=pending.len() {
1021 if end - start > 1 && predict::judge_section(&pending[start..end]).chars().count() > room {
1022 out.push(start..end - 1);
1023 start = end - 1;
1024 }
1025 }
1026 if start < pending.len() {
1027 out.push(start..pending.len());
1028 }
1029 out
1030}
1031
1032struct Batch {
1035 chars: usize,
1036 cut: bool,
1037 pred: Option<predict::Prediction>,
1038 ms: u64,
1039}
1040
1041fn ask_batch(batch: &[predict::Pending], sources: &[Source], budget: usize, cut: Cut, model: &str) -> Batch {
1045 let fixed = predict::judge_prompt("", batch).chars().count();
1046 let ids: Vec<&str> = batch.iter().map(|p| p.lesson.id.as_str()).collect();
1047 let anchors: Vec<Option<usize>> = sources.iter().map(|s| s.anchor(&ids)).collect();
1048 let (work, was_cut) = within(sources, &anchors, budget.saturating_sub(fixed), cut);
1049 let chars = fixed + work.chars().count();
1050 let started = std::time::Instant::now();
1051 let pred = predict::judge(&work, batch, model, end_timeout());
1052 Batch { chars, cut: was_cut, pred, ms: started.elapsed().as_millis() as u64 }
1053}
1054
1055struct Source {
1059 label: &'static str,
1060 text: Vec<char>,
1061 marks: Vec<(usize, Vec<String>)>,
1062}
1063
1064impl Source {
1065 fn of<'t>(label: &'static str, text: &'t str, keep: impl Fn(&'t str) -> Option<&'t str>, pending: &[predict::Pending]) -> Source {
1069 let mut out = Source { label, text: Vec::new(), marks: Vec::new() };
1070 let mut run: Vec<&str> = Vec::new();
1071 for l in text.lines() {
1072 if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
1073 out.push(&run, pending);
1074 run.clear();
1075 out.marks.push((out.text.len() + usize::from(!out.text.is_empty()), ids_in(list).map(str::to_string).collect()));
1077 } else if let Some(kept) = keep(l) {
1078 run.push(kept);
1079 }
1080 }
1081 out.push(&run, pending);
1082 if out.text.iter().all(|c| c.is_whitespace()) {
1083 out.text.clear();
1084 }
1085 out
1086 }
1087
1088 fn push(&mut self, lines: &[&str], pending: &[predict::Pending]) {
1090 if lines.is_empty() {
1091 return;
1092 }
1093 if !self.text.is_empty() {
1094 self.text.push('\n');
1095 }
1096 self.text.extend(without_declarations(&lines.join("\n"), pending).chars());
1097 }
1098
1099 fn anchor(&self, ids: &[&str]) -> Option<usize> {
1102 ids.iter().filter_map(|id| self.marks.iter().rev().find(|(_, l)| l.iter().any(|x| x == id)).map(|m| m.0)).min()
1103 }
1104}
1105
1106fn session_sources(session: &str, state: &str, pending: &[predict::Pending], cap: usize) -> Vec<Source> {
1113 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1114 let said = harness::session_texts(harness::current(), session, cap);
1115 vec![
1116 Source::of("The engineer's prompts and the agent's touches", state, |l| (!l.starts_with("#borg-")).then_some(l), pending),
1117 Source::of("The agent's edits and answers", &edits, |l| l.strip_prefix("#borg-answer ").or_else(|| (!l.starts_with("#borg-")).then_some(l)), pending),
1118 Source { label: "The agent's messages", text: without_declarations(&said, pending).chars().collect(), marks: Vec::new() },
1119 ]
1120}
1121
1122fn within(sources: &[Source], anchors: &[Option<usize>], budget: usize, cut: Cut) -> (String, bool) {
1128 let len: Vec<usize> = sources.iter().map(|s| s.text.len()).collect();
1129 let mut order: Vec<usize> = (0..sources.len()).filter(|i| len[*i] > 0).collect();
1130 let frame: usize = order.iter().map(|&i| sources[i].label.chars().count() + 6).sum();
1132 let total: usize = len.iter().sum();
1133 let mut left = if frame + total <= budget { total } else { budget.saturating_sub(frame + 2 * order.len()) };
1134 order.sort_by_key(|i| len[*i]);
1135 let mut keep = vec![0; sources.len()];
1136 for (k, &i) in order.iter().enumerate() {
1137 keep[i] = len[i].min(left / (order.len() - k));
1138 left -= keep[i];
1139 }
1140 let (mut out, mut was_cut) = (String::new(), false);
1141 for (i, s) in sources.iter().enumerate() {
1142 let n = keep[i];
1143 if n == 0 {
1144 continue;
1145 }
1146 let text = if n < len[i] {
1147 was_cut = true;
1148 cut.window(&s.text, n, anchors.get(i).copied().flatten())
1149 } else {
1150 s.text.iter().collect()
1151 };
1152 out.push_str(&format!("### {}\n{text}\n", s.label));
1153 }
1154 (out, was_cut)
1155}
1156
1157fn where_repo(session: &str) -> String {
1160 let text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
1161 text.lines().map(str::trim).rfind(|l| !l.is_empty() && !l.contains(['/', ' '])).unwrap_or_default().to_string()
1162}
1163
1164pub(crate) fn listed<'a>(context: &'a str, marker: &str) -> std::collections::HashSet<&'a str> {
1166 context.lines().filter_map(|l| l.strip_prefix(marker)).flat_map(ids_in).collect()
1167}
1168
1169fn ids_in(list: &str) -> impl Iterator<Item = &str> {
1171 tagged(list).0.split(',').map(str::trim).filter(|i| !i.is_empty())
1172}
1173
1174fn tag(check: &str) -> String {
1176 if check.is_empty() { String::new() } else { format!(" @{check}") }
1177}
1178
1179const MAX_EARLY_DELIVERIES: usize = 3;
1196
1197fn early(h: &dyn Harness, so_far: &SoFar, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<String> {
1198 if text.trim().is_empty()
1199 || std::env::var("BORG_EARLY").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
1200 || std::env::var_os("BORG_ONLY").is_some()
1201 || std::env::var_os("BORG_CRITIC").is_some()
1202 {
1203 return None;
1204 }
1205 let challenges = lessons(session);
1206 let context = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1207 let plain = h.caps().early_delivery && context.lines().filter(|l| l.starts_with("#borg-early ")).count() < MAX_EARLY_DELIVERIES;
1210 let standing = challenges.iter().any(conscience::Challenge::standing);
1211 if !standing && (!plain || challenges.iter().all(|c| c.scope.is_empty() && !c.consolidated)) {
1212 return None;
1213 }
1214 let settled = listed(&context, "#borg-topic ");
1215 let named = conscience::named_scopes(&challenges, &where_now(session));
1216 let mut topics: Vec<String> = if plain { named.iter().filter(|t| !settled.contains(t.as_str())).cloned().collect() } else { Vec::new() };
1217 if plain
1219 && topics.is_empty()
1220 && hook_event == "UserPromptSubmit"
1221 && !settled.contains(conscience::ANY)
1222 && challenges.iter().any(|c| c.consolidated && !c.standing())
1223 {
1224 topics.push(conscience::ANY.into());
1225 }
1226 let mut standing_topics = named;
1228 if hook_event == "UserPromptSubmit" {
1229 standing_topics.push(conscience::ANY.into());
1230 }
1231 let done = had_in(&context);
1232 let mut pool = if standing { conscience::standing_pool(&challenges, &standing_topics, &done) } else { Vec::new() };
1233 if !topics.is_empty() {
1234 pool.extend(conscience::early_pool(&challenges, &conscience::work_text(&context), &topics, &done));
1235 }
1236 let mut shown_topics = topics.clone();
1238 for t in standing_topics.into_iter().filter(|t| pool.iter().any(|(_, c)| c.standing() && c.scope.contains(t))) {
1239 if !shown_topics.contains(&t) {
1240 shown_topics.push(t);
1241 }
1242 }
1243 let selected: Vec<&conscience::Challenge> = conscience::explored(pool.iter().copied()).into_iter().take(conscience::EARLY_CAP).collect();
1246 let shown = conscience::render_early(&selected, &shown_topics);
1247 let delivered = &selected[..shown.as_ref().map_or(0, |(_, n)| *n)];
1248 let now_settled: Vec<&str> = topics
1251 .iter()
1252 .filter(|t| {
1253 !delivered.is_empty()
1255 || !challenges.iter().any(|c| {
1256 !c.standing()
1257 && (c.scope.contains(t) || (t.as_str() == conscience::ANY && c.consolidated))
1258 && !done.contains(c.id.as_str())
1259 && !delivered.iter().any(|d| d.id == c.id)
1260 })
1261 })
1262 .map(String::as_str)
1263 .collect();
1264 if !now_settled.is_empty() {
1265 append(session, &format!("#borg-topic {}", now_settled.join(",")));
1266 }
1267 let (text, _) = shown?;
1268 let ids = delivered.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
1269 let check = crate::select::check_id(session, "early", learn::now_ms(), std::process::id());
1271 let line = format!("#borg-early {ids} @{check}");
1272 append(session, &line);
1273 note_work(session, so_far, &line);
1274 let p = conscience::early_propensity(&pool, session, &shown_topics);
1275 let p: Vec<f64> = delivered.iter().map(|c| p.get(&c.id).copied().unwrap_or(0.0)).collect();
1276 record_delivery(session, input, delivered, "early", &check, p, early_rel(&challenges, &conscience::work_text(&context), &topics, delivered));
1277 log_cost(session, &format!("free event={hook_event} lessons={} chars={}", delivered.len(), text.len()));
1278 crate::stats::count(&learn::home(), crate::stats::EARLY, session, |e| {
1279 e.n = delivered.len() as u64;
1280 e.lessons = delivered.iter().map(|c| c.id.clone()).collect();
1281 e.channel = "early".into();
1282 });
1283 Some(text)
1284}
1285
1286fn early_rel(challenges: &[conscience::Challenge], work: &str, topics: &[String], delivered: &[&conscience::Challenge]) -> Vec<f64> {
1292 let Some(&(top, _)) = conscience::early_pool(challenges, work, topics, &std::collections::HashSet::new()).first() else { return Vec::new() };
1293 let ranked = conscience::rank(challenges, work);
1294 delivered.iter().map(|c| outcome::round_p(ranked.iter().find(|(_, x)| x.id == c.id).map_or(0.0, |(s, _)| *s) / top)).collect()
1295}
1296
1297fn note_work(session: &str, so_far: &SoFar, lines: &str) {
1301 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
1302 let _ = writeln!(f, "{WORK}{}\n{lines}", one_line(so_far.get(), EVIDENCE_CHARS));
1303 }
1304}
1305
1306fn record_delivery(session: &str, input: &Value, delivered: &[&conscience::Challenge], channel: &str, check: &str, p: Vec<f64>, rel: Vec<f64>) {
1312 if !learning() || delivered.is_empty() {
1313 return;
1314 }
1315 let lessons: Vec<String> = delivered.iter().map(|c| c.id.clone()).collect();
1316 let (grades, ranks, applied) = graded_of(session, &lessons);
1317 let asked = outcome::Outcome {
1318 kind: outcome::ASKED.into(),
1319 session: session.into(),
1320 lessons,
1321 ms: learn::now_ms(),
1322 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
1323 groups: conscience::disagreement_ids(delivered),
1324 channel: channel.into(),
1325 grades,
1326 ranks,
1327 applied,
1328 p: p.into_iter().map(outcome::round_p).collect(),
1329 check: check.into(),
1330 rel,
1331 ..Default::default()
1332 };
1333 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
1334}
1335
1336fn record_route(session: &str, input: &Value, check: &str, lessons: Vec<String>, channel: &str, route: &str, reason: &str) {
1339 if !learning() || lessons.is_empty() {
1340 return;
1341 }
1342 let o = outcome::Outcome {
1343 kind: outcome::DELIVERY.into(),
1344 session: session.into(),
1345 lessons,
1346 ms: learn::now_ms(),
1347 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
1348 channel: channel.into(),
1349 check: check.into(),
1350 route: route.into(),
1351 reason: reason.into(),
1352 ..Default::default()
1353 };
1354 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &o);
1355}
1356
1357fn record_routes(session: &str, input: &Value, routed: &[(&conscience::Challenge, &str)], channel: &str, route: &str, reason: &str) {
1359 for (check, ids) in by_check(routed) {
1360 record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, route, reason);
1361 }
1362}
1363
1364fn by_check<'a>(routed: &[(&'a conscience::Challenge, &'a str)]) -> Vec<(&'a str, Vec<&'a str>)> {
1366 let mut out: Vec<(&str, Vec<&str>)> = Vec::new();
1367 for (c, check) in routed {
1368 match out.iter_mut().find(|(k, _)| k == check) {
1369 Some((_, ids)) => ids.push(c.id.as_str()),
1370 None => out.push((check, vec![c.id.as_str()])),
1371 }
1372 }
1373 out
1374}
1375
1376fn queued_file(session: &str) -> PathBuf {
1383 state_file(session).with_extension("queued")
1384}
1385
1386#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1388enum Queued {
1389 Ask,
1391 Instead,
1393 Free,
1394 Placebo,
1395}
1396
1397const INSTEAD_NEXT: &str = "instead-next ";
1403
1404const LEGACY_ASK: [&str; 2] = ["live ", "live-next "];
1410
1411const LEGACY_INSTEAD: &str = "instead ";
1414
1415fn queue_line(line: &str) -> (Queued, Vec<&str>, &str) {
1417 let ask = LEGACY_ASK.iter().find_map(|p| line.strip_prefix(p));
1418 let instead = line.strip_prefix(INSTEAD_NEXT).or_else(|| line.strip_prefix(LEGACY_INSTEAD));
1419 let (route, list) = match (ask, line.strip_prefix("placebo "), instead) {
1420 (Some(l), _, _) => (Queued::Ask, l),
1421 (_, Some(l), _) => (Queued::Placebo, l),
1422 (_, _, Some(l)) => (Queued::Instead, l),
1423 _ => (Queued::Free, line),
1424 };
1425 (route, ids_in(list).collect(), tagged(list).1.unwrap_or_default())
1426}
1427
1428fn enqueue(session: &str, line: &str) {
1431 let q = queued_file(session);
1432 live::locked(&q, || {
1433 let _ = std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
1434 });
1435}
1436
1437fn take_queue(session: &str) -> Option<String> {
1440 let q = queued_file(session);
1441 let taken = state_file(session).with_extension(format!("queued.{}", std::process::id()));
1442 live::locked(&q, || std::fs::rename(&q, &taken)).ok()?;
1443 let text = std::fs::read_to_string(&taken).unwrap_or_default();
1444 let _ = std::fs::remove_file(&taken);
1445 Some(text)
1446}
1447
1448fn waiting_in(text: &str) -> impl Iterator<Item = &str> {
1452 text.lines().map(queue_line).filter(|(route, _, _)| *route != Queued::Placebo).flat_map(|(_, ids, _)| ids)
1453}
1454
1455fn note_never_delivered(session: &str, input: &Value) {
1459 let text = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1460 for line in text.lines() {
1461 let (route, ids, check) = queue_line(line);
1462 let channel = match route {
1463 Queued::Ask => "live",
1464 Queued::Instead | Queued::Free => "queued",
1465 Queued::Placebo => outcome::PLACEBO,
1466 };
1467 record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, outcome::ROUTE_NEVER, "session ended");
1468 }
1469}
1470
1471fn tagged_p(state: &str, prefix: &str, check: &str, ids: &[&str]) -> Vec<f64> {
1474 let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
1475 for line in state.lines().filter_map(|l| l.strip_prefix(prefix)) {
1476 let (items, at) = tagged(line);
1477 if at != Some(check) {
1478 continue;
1479 }
1480 for (id, p) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
1481 if let Ok(p) = p.trim().parse::<f64>() {
1482 seen.insert(id.trim(), p);
1483 }
1484 }
1485 }
1486 ids.iter().map(|i| seen.get(i).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
1487}
1488
1489type Pairs<'a> = Vec<(&'a conscience::Challenge, &'a str)>;
1491
1492type Drop<'a> = (&'a str, &'static str, &'static str, &'static str);
1495
1496fn queued(so_far: &SoFar, hook_event: &str, session: &str, input: &Value) -> Option<String> {
1509 let text = take_queue(session)?;
1510 let all = lessons(session);
1511 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1512 let had = had_in(&state);
1513 let mut done: Option<std::collections::HashSet<&str>> = None;
1514 let mut lines: Vec<(Queued, Vec<&str>, &str)> = text.lines().map(queue_line).collect();
1515 lines.sort_by_key(|l| l.0);
1518 let placebos = lines.iter().position(|l| l.0 == Queued::Placebo).unwrap_or(lines.len());
1519 let (mut wanted, mut placebo): (Pairs, Pairs) = (Vec::new(), Vec::new());
1520 let mut dropped: Vec<(Drop, &str)> = Vec::new();
1521 let (mut owed, mut asks) = (0, 0);
1524 for (route, ids, check) in &lines[..placebos] {
1525 let channel = if *route == Queued::Ask { "live" } else { "queued" };
1526 for &id in ids {
1527 let Some(c) = all.iter().find(|c| c.id == id) else {
1528 dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
1529 continue;
1530 };
1531 let why = if wanted.iter().any(|(c, _)| c.id == id) {
1532 Some("queued twice")
1533 } else if *route == Queued::Ask {
1534 done.get_or_insert_with(|| done_in(&state, &early_acted(session, &all, ""))).contains(id).then_some("done")
1535 } else {
1536 had.contains(id).then_some("given before")
1537 };
1538 match why {
1539 Some(why) => dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, why), id)),
1540 None => {
1541 owed += usize::from(*route != Queued::Free);
1542 asks += usize::from(*route == Queued::Ask);
1543 wanted.push((c, check));
1544 }
1545 }
1546 }
1547 }
1548 let free: Vec<&conscience::Challenge> = wanted.iter().map(|(c, _)| *c).collect();
1549 let shown = conscience::render_owing(&free, &[], owed);
1550 let n = shown.as_ref().map_or(0, |(_, n)| *n);
1551 dropped.extend(wanted[n..].iter().map(|(c, check)| ((*check, "queued", outcome::ROUTE_DROPPED_CHARS, "budget"), c.id.as_str())));
1552 for (_, ids, check) in &lines[placebos..] {
1555 for &id in ids {
1556 let Some(c) = all.iter().find(|c| c.id == id) else {
1557 dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
1558 continue;
1559 };
1560 let why = if placebo.iter().any(|(c, _)| c.id == id) {
1561 Some("queued twice")
1562 } else {
1563 (wanted[..n].iter().any(|(c, _)| c.id == id) || had.contains(id)).then_some("delivered")
1564 };
1565 match why {
1566 Some(why) => dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, why), id)),
1567 None => placebo.push((c, check)),
1568 }
1569 }
1570 }
1571 let mut parts: Vec<String> = Vec::new();
1572 let mut markers: Vec<String> = Vec::new();
1573 if let Some((text, n)) = shown {
1574 let now = learn::now_ms();
1577 for (check, ids) in by_check(&wanted[..asks]) {
1578 let list = ids.join(",");
1579 markers.push(format!("#borg-asked {list}{}\n#borg-live {list} ms={now}", tag(check)));
1580 let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
1581 record_asked(session, input, &members, "live", check);
1582 }
1583 for (check, ids) in by_check(&wanted[asks..n]) {
1584 markers.push(format!("#borg-queued {}{}", ids.join(","), tag(check)));
1585 let delivered: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
1586 let p = if check.is_empty() { vec![1.0; ids.len()] } else { tagged_p(&state, "#borg-pf ", check, &ids) };
1589 record_delivery(session, input, &delivered, "queued", check, p, Vec::new());
1590 }
1591 record_routes(session, input, &wanted[asks..n], "queued", outcome::ROUTE_FREE, "");
1592 log_cost(session, &format!("free event={hook_event} lessons={n} chars={} graded dropped={}", text.len(), wanted.len() - n));
1593 crate::stats::count(&learn::home(), crate::stats::QUEUED_FREE, session, |e| {
1594 e.n = n as u64;
1595 e.lessons = wanted[..n].iter().map(|(c, _)| c.id.clone()).collect();
1596 e.channel = "queued".into();
1597 });
1598 parts.push(text);
1599 }
1600 for (check, ids) in by_check(&placebo) {
1602 markers.push(format!("#borg-placebo {}{}", ids.join(","), tag(check)));
1603 }
1604 record_routes(session, input, &placebo, outcome::PLACEBO, outcome::ROUTE_PLACEBO, "");
1605 if !markers.is_empty() {
1606 let markers = markers.join("\n");
1607 append(session, &markers);
1608 note_work(session, so_far, &markers);
1609 }
1610 let mut groups: Vec<(Drop, Vec<String>)> = Vec::new();
1611 for (key, id) in dropped {
1612 match groups.iter_mut().find(|(k, _)| *k == key) {
1613 Some((_, ids)) => ids.push(id.to_string()),
1614 None => groups.push((key, vec![id.to_string()])),
1615 }
1616 }
1617 for ((check, channel, route, why), ids) in groups {
1618 record_route(session, input, check, ids, channel, route, why);
1619 }
1620 (!parts.is_empty()).then(|| parts.join("\n\n"))
1621}
1622
1623fn work_so_far(h: &dyn Harness, input: &Value) -> String {
1626 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1627 harness::work_so_far(h.caps(), input, &turn_file(session), EVIDENCE_CHARS)
1628}
1629
1630struct SoFar<'a> {
1634 h: &'a dyn Harness,
1635 input: &'a Value,
1636 text: std::cell::OnceCell<String>,
1637}
1638
1639impl<'a> SoFar<'a> {
1640 fn new(h: &'a dyn Harness, input: &'a Value) -> Self {
1641 SoFar { h, input, text: std::cell::OnceCell::new() }
1642 }
1643
1644 fn get(&self) -> &str {
1645 self.text.get_or_init(|| work_so_far(self.h, self.input))
1646 }
1647}
1648
1649fn turn_file(session: &str) -> PathBuf {
1652 state_file(session).with_extension("turn")
1653}
1654
1655fn queen_ask(h: &dyn Harness, session: &str, cwd: &str) -> Option<String> {
1661 if !learning() || !crate::queen::ask_enabled() || std::env::var_os("BORG_ONLY").is_some() || std::env::var_os("BORG_CRITIC").is_some() {
1662 return None;
1663 }
1664 let repos = || {
1665 let mut repos = crate::systems::work_repos(&std::fs::read_to_string(where_file(session)).unwrap_or_default());
1666 let here = repo_of(cwd);
1667 if !here.is_empty() && !repos.contains(&here) {
1668 repos.insert(0, here);
1669 }
1670 repos
1671 };
1672 let scope = if cwd.is_empty() { String::new() } else { format!(" --dir {}", crate::queen::sh_quote(cwd)) };
1673 let mut q = crate::queen::deliver(&learn::home(), session, repos, &scope, learn::now_ms())?;
1674 if let Ok(exe) = std::env::current_exe() {
1675 q = q.run_by(&crate::queen::sh_quote(&exe.display().to_string()));
1676 }
1677 let text = crate::queen::render_delivery(&q, h.caps().question_dialog);
1678 log_cost(session, &format!("free event=UserPromptSubmit queen={} chars={}", q.id, text.len()));
1679 Some(text)
1680}
1681
1682fn free_context(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<Value> {
1684 free_context_with(h, hook_event, session, input, text, None)
1685}
1686
1687fn free_context_with(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str, extra: Option<String>) -> Option<Value> {
1689 let so_far = SoFar::new(h, input);
1690 let parts: Vec<String> =
1691 [queued(&so_far, hook_event, session, input), early(h, &so_far, hook_event, session, input, text), extra].into_iter().flatten().collect();
1692 (!parts.is_empty()).then(|| json!({"hookSpecificOutput": {"hookEventName": hook_event, "additionalContext": parts.join("\n\n")}}))
1693}
1694
1695enum Verdict {
1697 Skip(String),
1699 Failed,
1701 Ask(Vec<String>),
1703}
1704
1705struct Work<'a> {
1707 all: &'a [conscience::Challenge],
1709 judged: &'a [conscience::Challenge],
1712 challenges: &'a [conscience::Challenge],
1715 done: &'a std::collections::HashSet<&'a str>,
1716 state: &'a str,
1718 context: &'a str,
1721 scope_text: &'a str,
1722 message: &'a str,
1724 changed: bool,
1726 judge: bool,
1729 budget: Option<std::time::Duration>,
1732 started: std::time::Instant,
1733 check: &'a str,
1736 summary: &'a crate::select::summary::Summary,
1740}
1741
1742struct Call<'a> {
1744 eligible: usize,
1747 order: &'a [&'a conscience::Challenge],
1748 tail: &'a [(usize, &'a conscience::Challenge)],
1752 pt: f64,
1753 budget: usize,
1755 model: &'a str,
1756 prompt_hash: String,
1757 dur_ms: u64,
1758 usd: f64,
1760}
1761
1762fn check(session: &str, input: &Value, w: &Work) -> Verdict {
1771 let skip = if prefilter::edits_gate() {
1774 (!w.changed).then(|| "noedit".to_string())
1775 } else {
1776 prefilter::skip(w.challenges, w.context).map(|score| format!("{score:.2}"))
1777 };
1778 if let Some(score) = skip {
1779 return Verdict::Skip(score);
1780 }
1781 let text = conscience::work_text(w.context);
1783 let text = text.as_ref();
1784 let started = std::time::Instant::now();
1788 let shortlist_now = || {
1789 let (list, added) = crate::vectors::shortlist(&lessons_path(), w.challenges, text, w.scope_text, crate::vectors::SHORTLIST);
1790 if crate::vectors::enabled() {
1791 log_cost(session, &format!("vectors added={added} ms={}", started.elapsed().as_millis()));
1792 }
1793 list
1794 };
1795 let whole = crate::whole::on();
1799 let (shortlist, eligible, order, tail, pt, budget) = if whole {
1800 let ranked = conscience::rank_scoped(w.challenges, text, w.scope_text);
1801 let eligible = crate::whole::eligible(w.challenges, &ranked, w.scope_text);
1802 let n = eligible.len();
1803 let tokens = match std::env::var_os("BORG_STOP_POOL_TOKENS") {
1807 Some(_) => crate::whole::tokens(),
1808 None => {
1809 let limit = live::call_limit(w.budget, w.started.elapsed());
1810 crate::select::pool_tokens(w.summary, Some(u64::try_from(limit.as_millis()).unwrap_or(u64::MAX)), session)
1811 }
1812 };
1813 let f = crate::whole::select_filled(eligible, shortlist_now, &ranked, tokens);
1814 let (drawn, pt) = if f.order.is_empty() { (Vec::new(), 0.0) } else { crate::whole::tail(&f.dropped, tail_seed(session, w.check, w.summary)) };
1819 let tail: Vec<(usize, &conscience::Challenge)> = drawn.into_iter().map(|(i, c)| (f.order.len() + i, c)).collect();
1820 let sampled = if tail.is_empty() { String::new() } else { format!(" tail={}", tail.len()) };
1821 log_cost(session, &format!("pool mode=all eligible={n} shown={} budget={}{sampled}", f.shown.len(), if f.fell { "full" } else { "fits" }));
1822 let mut shown = f.shown;
1823 shown.extend(tail.iter().map(|(_, c)| *c));
1824 (shown, n, f.order, tail, pt, tokens)
1825 } else {
1826 let list = shortlist_now();
1827 (list.clone(), list.len(), list, Vec::new(), 0.0, 0)
1828 };
1829 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
1830 let judging = if w.judge {
1832 pending(session, w.state, w.judged, w.message, When::Stop, w.check)
1833 } else {
1834 Judging { pending: Vec::new(), question: false, crowded: Vec::new() }
1835 };
1836 let pend = &judging.pending;
1837 if shortlist.is_empty() && !judging.question {
1840 return Verdict::Skip("empty".into());
1841 }
1842 let extra = predict::judge_section(pend).len();
1843 let graded = crate::whole::graded();
1844 let compact = whole && !crate::whole::classic();
1845 let timeout = live::call_limit(w.budget, w.started.elapsed());
1846 let shown_work = without_declarations(text, pend);
1849 let called = std::time::Instant::now();
1850 let pred = if compact {
1851 predict::predict_whole(&shown_work, &shortlist, pend, &model, graded, timeout)
1852 } else {
1853 predict::predict(&shown_work, &shortlist, pend, &model, graded, timeout)
1854 };
1855 let dur_ms = called.elapsed().as_millis() as u64;
1856 stat(session, input, crate::stats::CHECK, |e| {
1857 e.n = shortlist.len() as u64;
1858 e.dur_ms = dur_ms;
1859 e.usd = pred.as_ref().map_or(0.0, |p| p.cost);
1860 if pred.is_none() {
1861 e.why = "failed".into();
1862 }
1863 });
1864 let usd = pred.as_ref().map_or(0.0, |p| p.cost);
1865 let call = Call { eligible, order: &order, tail: &tail, pt, budget, model: &model, prompt_hash: predict::template_hash(compact, graded), dur_ms, usd };
1866 note_unjudged(session, &judging.crowded, "slots");
1867 let Some(pred) = pred else {
1868 if graded {
1870 record_graded(session, input, w, &[], None, &call);
1871 }
1872 note_unjudged(session, &pend.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>(), "failed");
1873 return Verdict::Failed;
1874 };
1875 append(session, &format!("#borg-predict-cost {:.4}", pred.cost));
1877 if !pend.is_empty() {
1878 let call = w.state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1;
1879 let logged = Logged { call, chars: extra, model: judge_model(&model), fields: format!(" prompt={}", pred.prompt) };
1881 record_verdicts(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()), "", pend, &pred, &logged);
1882 }
1883 let applied: Vec<(&conscience::Challenge, u8)> =
1884 pred.applies.iter().zip(&pred.grades).filter_map(|(i, g)| shortlist.iter().find(|c| &c.id == i).map(|c| (*c, *g))).collect();
1885 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1886 if graded {
1887 let lessons = lessons_path();
1891 let at = Decide {
1894 session,
1895 check: w.check,
1896 channel: "stop",
1897 lessons: &lessons,
1898 tokens: crate::select::shown_tokens(call.order),
1899 recorded: records_reach_gate(&lessons),
1900 };
1901 let r = decide(crate::select::policy(), &applied, &at, w.summary, DECIDE_WAIT, |r, basis| {
1902 record_graded(session, input, w, &applied, Some((r, basis)), &call)
1903 });
1904 let routed = |cs: &[&conscience::Challenge], p: &[f64]| -> Vec<(&str, f64)> {
1905 applied.iter().zip(p).filter(|((c, _), _)| cs.iter().any(|x| x.id == c.id)).map(|((c, _), p)| (c.id.as_str(), *p)).collect()
1906 };
1907 let mut lines = vec![graded_line(&applied), r.log.line(applied.len(), w.check)];
1908 lines.extend(r.log.attention.clone());
1909 lines.extend(p_line("#borg-p", routed(&r.ask, &r.p), Some(w.check)));
1910 lines.extend(p_line("#borg-pf", routed(&r.free, &r.pf), Some(w.check)));
1911 append(session, &lines.join("\n"));
1912 let queue = !r.free.is_empty();
1913 log_cost(session, &format!("graded applied={} ask={} free={} {}", applied.len(), r.ask.len(), r.free.len(), if queue { "queued" } else { "dropped" }));
1914 if queue {
1915 let asks = if r.log.policy == crate::select::Policy::Learned.name() { conscience::graded_without(&applied, None, &[]).0 } else { Vec::new() };
1919 let (owed, rest): (Vec<&conscience::Challenge>, Vec<&conscience::Challenge>) = r.free.iter().partition(|c| asks.iter().any(|a| a.id == c.id));
1920 if !owed.is_empty() {
1921 enqueue(session, &format!("{INSTEAD_NEXT}{}{}", ids(&owed).join(","), tag(w.check)));
1922 }
1923 if !rest.is_empty() {
1924 enqueue(session, &format!("{}{}", ids(&rest).join(","), tag(w.check)));
1925 }
1926 }
1927 if !r.placebo.is_empty() {
1928 enqueue(session, &format!("placebo {}{}", ids(&r.placebo).join(","), tag(w.check)));
1929 }
1930 Verdict::Ask(ids(&r.ask))
1931 } else {
1932 let predicted: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
1935 let pool = conscience::widen_pool(&conscience::rank_scoped(w.all, text, w.scope_text), &predicted, w.done);
1936 let widened: Vec<&conscience::Challenge> =
1937 conscience::in_scope_first(conscience::explored(pool.iter().copied())).into_iter().filter(|c| w.challenges.iter().any(|x| x.id == c.id)).collect();
1938 append_p_of(session, &pool, &widened, w.check);
1939 Verdict::Ask(ids(&widened))
1941 }
1942}
1943
1944struct Decide<'x> {
1946 session: &'x str,
1947 check: &'x str,
1948 channel: &'static str,
1949 lessons: &'x std::path::Path,
1950 tokens: u32,
1952 recorded: bool,
1955}
1956
1957struct Basis<'s> {
1962 summary: &'s crate::select::summary::Summary,
1963 had: &'s [String],
1964 why: &'static str,
1965}
1966
1967const DECIDE_WAIT: std::time::Duration = std::time::Duration::from_millis(live::AFTER_CALL.as_millis() as u64 / 3);
1974
1975const DECIDE_POLL: std::time::Duration = std::time::Duration::from_millis(5);
1977
1978fn decide_lock(lessons: &std::path::Path) -> PathBuf {
1980 crate::select::summary::cache_path(lessons).with_extension("json.lock")
1981}
1982
1983fn lock_within(file: &std::path::Path, wait: std::time::Duration) -> Result<std::fs::File, &'static str> {
1987 let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file).map_err(|_| outcome::DECIDED_NO_LOCK)?;
1988 let until = std::time::Instant::now() + wait;
1989 loop {
1990 match lock.try_lock() {
1991 Ok(()) => return Ok(lock),
1992 Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < until => std::thread::sleep(DECIDE_POLL),
1993 Err(std::fs::TryLockError::WouldBlock) => return Err(outcome::DECIDED_BUSY),
1994 Err(std::fs::TryLockError::Error(_)) => return Err(outcome::DECIDED_NO_LOCK),
1995 }
1996 }
1997}
1998
1999fn decide<'a>(
2015 policy: crate::select::Policy,
2016 applied: &[(&'a conscience::Challenge, u8)],
2017 at: &Decide,
2018 stale: &crate::select::summary::Summary,
2019 wait: std::time::Duration,
2020 mut record: impl FnMut(&crate::select::Routed<'a>, &Basis) -> bool,
2021) -> crate::select::Routed<'a> {
2022 use crate::select::Policy;
2023 let state = std::fs::read_to_string(state_file(at.session)).unwrap_or_default();
2024 let queue = std::fs::read_to_string(queued_file(at.session)).unwrap_or_default();
2025 let mut had: Vec<String> = had_in(&state).into_iter().chain(waiting_in(&queue)).map(str::to_string).collect();
2026 had.sort_unstable();
2027 had.dedup();
2028 let alpha = crate::select::alpha(at.lessons);
2030 let mut on = |summary: &crate::select::summary::Summary, policy: Policy, why: &'static str| {
2032 let ctx =
2033 crate::select::Ctx { session: at.session, check: at.check, channel: at.channel, lessons: at.lessons, summary, tokens: at.tokens, had: &had, alpha };
2034 let r = crate::select::graded_under(policy, applied, &ctx);
2035 let landed = record(&r, &Basis { summary, had: &had, why });
2036 (r, landed)
2037 };
2038 if policy != Policy::Learned {
2039 return on(stale, policy, "").0;
2040 }
2041 if !at.recorded {
2042 return on(stale, Policy::Fixed, outcome::DECIDED_NO_RECORD).0;
2043 }
2044 match lock_within(&decide_lock(at.lessons), wait) {
2045 Ok(_held) => {
2046 let summary = crate::select::summary::refresh(at.lessons);
2047 match on(&summary, policy, "") {
2048 (r, true) => r,
2049 (_, false) => on(&summary, Policy::Fixed, outcome::DECIDED_NO_RECORD).0,
2051 }
2052 }
2053 Err(why) => on(stale, Policy::Fixed, why).0,
2054 }
2055}
2056
2057fn records_reach_gate(lessons: &std::path::Path) -> bool {
2062 learning() && outcome::path_beside(lessons) == graded_log()
2063}
2064
2065fn graded_log() -> PathBuf {
2067 learn::home().join("outcomes.jsonl")
2068}
2069
2070fn tail_seed(session: &str, check: &str, summary: &crate::select::summary::Summary) -> u64 {
2073 let ctx = crate::select::Ctx {
2074 session,
2075 check,
2076 channel: "stop",
2077 lessons: std::path::Path::new(""),
2078 summary,
2079 tokens: 0,
2080 had: &[],
2081 alpha: crate::select::policy::ALPHA,
2082 };
2083 crate::select::seed(&ctx) ^ u64::from_be_bytes(*b"tailpool")
2084}
2085
2086fn record_graded(
2089 session: &str,
2090 input: &Value,
2091 w: &Work,
2092 applied: &[(&conscience::Challenge, u8)],
2093 decided: Option<(&crate::select::Routed, &Basis)>,
2094 call: &Call,
2095) -> bool {
2096 if !learning() {
2097 return false;
2098 }
2099 let repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
2100 let o = graded_outcome(session, &repo, w.check, applied, decided, w.summary, call);
2101 outcome::append(&graded_log(), &o).is_ok()
2102}
2103
2104#[allow(clippy::too_many_arguments)] fn graded_outcome(
2116 session: &str,
2117 repo: &str,
2118 check: &str,
2119 applied: &[(&conscience::Challenge, u8)],
2120 decided: Option<(&crate::select::Routed, &Basis)>,
2121 read: &crate::select::summary::Summary,
2122 call: &Call,
2123) -> outcome::Outcome {
2124 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
2125 let rounded = |p: &[f64]| p.iter().map(|x| outcome::round_p(*x)).collect::<Vec<_>>();
2126 let (routed, basis) = (decided.map(|d| d.0), decided.map(|d| d.1));
2127 let shown: u32 = call.order.len().try_into().unwrap_or(u32::MAX);
2128 let place = |id: &str| call.order.iter().position(|x| x.id == id).or_else(|| call.tail.iter().find(|(_, x)| x.id == id).map(|(at, _)| *at));
2129 outcome::Outcome {
2130 kind: outcome::GRADED.into(),
2131 session: session.into(),
2132 lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
2133 ms: learn::now_ms(),
2134 repo: repo.into(),
2135 channel: "stop".into(),
2136 grades: applied.iter().map(|(_, g)| *g).collect(),
2137 ranks: (0..applied.len()).map(|r| u16::try_from(r).unwrap_or(u16::MAX)).collect(),
2138 applied: u16::try_from(applied.len()).unwrap_or(u16::MAX),
2139 chosen: routed.map(|r| ids(&r.ask)).unwrap_or_default(),
2140 explore: routed.is_some_and(|r| r.log.source == "explore"),
2141 p: routed.map(|r| rounded(&r.p)).unwrap_or_default(),
2142 check: check.into(),
2143 policy: routed.map_or_else(|| crate::select::policy().name(), |r| r.log.policy).into(),
2144 fit: routed.map_or_else(|| read.fit.key.clone(), |r| r.log.fit.clone()),
2145 seen: basis.map(|b| b.summary.key()).unwrap_or_default(),
2147 source: routed.map(|r| r.log.source).unwrap_or_default().into(),
2148 free: routed.map(|r| ids(&r.free)).unwrap_or_default(),
2149 pf: routed.map(|r| rounded(&r.pf)).unwrap_or_default(),
2150 placebo: routed.map(|r| ids(&r.placebo)).unwrap_or_default(),
2151 psi: routed.map_or(0.0, |r| r.log.psi),
2152 nofree: basis.map(|b| crate::select::nofree(applied, b.had)).unwrap_or_default(),
2153 xk: routed.and_then(|r| r.log.xk),
2155 xs: routed.and_then(|r| r.log.xs),
2156 alpha: routed.and_then(|r| r.log.alpha),
2158 tail: call.tail.iter().map(|(_, c)| c.id.clone()).collect(),
2159 pt: call.pt,
2160 reason: basis.map(|b| b.why).unwrap_or_default().into(),
2161 eligible: call.eligible.try_into().unwrap_or(u32::MAX),
2162 shown,
2163 tokens: crate::select::shown_tokens(call.order),
2164 budget: call.budget.try_into().unwrap_or(u32::MAX),
2165 fill: applied
2167 .iter()
2168 .map(|(c, _)| place(&c.id))
2169 .chain(call.tail.iter().map(|(at, _)| Some(*at)))
2170 .map(|i| i.map_or(u16::MAX, |i| u16::try_from(i).unwrap_or(u16::MAX)))
2171 .collect(),
2172 model: call.model.into(),
2173 prompt_hash: call.prompt_hash.clone(),
2174 harness: std::env::var("BORG_HARNESS").unwrap_or_default(),
2175 version: env!("CARGO_PKG_VERSION").into(),
2176 dur_ms: call.dur_ms,
2177 failed: routed.is_none(),
2178 usd: call.usd,
2179 ..Default::default()
2180 }
2181}
2182
2183pub fn dispatch(h: &dyn Harness, event: &str, input: &Value) -> Option<Value> {
2186 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2187 match event {
2188 "prompt" => on_prompt(h, session, input),
2189 "post_tool_failure" => {
2190 capture_test(session, input, true);
2191 if h.caps().deny_capture {
2192 capture_rejection(h, session, input);
2193 }
2194 None
2195 }
2196 "end" => {
2197 on_end(h, session, input);
2198 None
2199 }
2200 "post_tool" => {
2201 capture_test(session, input, false);
2202 on_post_tool(h, session, input)
2203 }
2204 "stop" => on_stop(h, session, input),
2205 _ => None,
2206 }
2207}
2208
2209fn on_prompt(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2210 let prompt = input.get("prompt").and_then(Value::as_str).unwrap_or_default();
2211 append(session, prompt);
2212 if learning() {
2213 crate::notes::remember_start(&crate::notes::state_dir(), session, input.get("cwd").and_then(Value::as_str).unwrap_or_default());
2215 crate::notes::mark(&crate::notes::state_dir(), session, "prompt");
2216 }
2217 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(prompts_file(session)) {
2219 let _ = writeln!(f, "{prompt}");
2220 }
2221 let vetoed = (learning() && crate::veto::enabled()).then(|| crate::veto::reason_in(prompt)).flatten();
2223 match &vetoed {
2224 Some(reason) => veto(h, session, input, reason),
2225 None => capture_prompt(h, session, input, prompt),
2226 }
2227 note_where(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()));
2228 let _ = std::fs::write(state_file(session).with_extension("lastprompt"), prompt);
2229 let checked = crate::update::maybe_check(learn::now_ms());
2232 let out = (h.caps().prompt_context && input.get("context") != Some(&Value::Bool(false))).then(|| {
2235 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2236 let ask = queen_ask(h, session, cwd);
2237 let update = crate::update::notice(checked.as_ref(), &learn::home(), &state_file(session).with_extension("update"));
2240 let extra: Vec<String> = [ask, vetoed.as_deref().map(crate::veto::render), update].into_iter().flatten().collect();
2241 free_context_with(h, "UserPromptSubmit", session, input, prompt, (!extra.is_empty()).then(|| extra.join("\n\n")))
2242 });
2243 if harness::transcript(h.caps(), input).is_none() {
2245 let _ = std::fs::write(turn_file(session), "");
2246 }
2247 out.flatten()
2248}
2249
2250fn capture_test(session: &str, input: &Value, failed: bool) {
2255 if !learning() || input.get("tool_name").and_then(Value::as_str) != Some("Bash") {
2256 return;
2257 }
2258 let command = input.pointer("/tool_input/command").and_then(Value::as_str).unwrap_or_default();
2259 if !crate::notes::regress_signals::runs_tests(command) {
2260 return;
2261 }
2262 let output = crate::notes::regress_signals::tool_output(input);
2263 if !failed && output.trim().is_empty() {
2264 return;
2265 }
2266 if let Some(what) = crate::notes::regress_signals::test_outcome(command, &output, failed)
2267 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session).with_extension("tests"))
2268 {
2269 let _ = writeln!(f, "{what} {}", learn::now_ms());
2270 }
2271}
2272
2273fn on_end(h: &dyn Harness, session: &str, input: &Value) {
2274 let _ = std::fs::remove_file(denied_file(session));
2275 if !learning() {
2276 return;
2277 }
2278 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
2280 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
2281 note_never_delivered(session, input);
2285 let end = outcome::Outcome {
2286 kind: outcome::ENDED.into(),
2287 session: session.into(),
2288 ms: learn::now_ms(),
2289 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
2290 ..Default::default()
2291 };
2292 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &end);
2293 if !crate::notes::on_end(session, input)
2296 && let Ok(exe) = std::env::current_exe()
2297 {
2298 let _ = std::process::Command::new(exe)
2299 .args(["learn", "--session", session])
2300 .stdin(std::process::Stdio::null())
2301 .stdout(std::process::Stdio::null())
2302 .stderr(std::process::Stdio::null())
2303 .spawn();
2304 }
2305}
2306
2307fn on_post_tool(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2308 let _ = std::fs::remove_file(denied_file(session));
2310 let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or_default();
2311 let tool_input = input.get("tool_input").unwrap_or(&Value::Null);
2312 let touch = touched(tool_input, tool);
2313 append(session, &touch);
2314 note_where(
2316 session,
2317 &if tool == "Bash" { bash_read_paths(tool_input.get("command").and_then(Value::as_str).unwrap_or_default()).join(" ") } else { touch.clone() },
2318 );
2319 if prefilter::edits_gate() && prefilter::changes(tool, tool_input) {
2321 let _ = std::fs::OpenOptions::new().create(true).append(true).open(changes_file(session)).and_then(|mut f| writeln!(f, "{tool}"));
2322 }
2323 let edit = edit_text(tool, tool_input);
2324 if let Some(edit) = &edit
2325 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
2326 {
2327 let _ = writeln!(f, "{edit}");
2328 }
2329 if harness::transcript(h.caps(), input).is_none()
2331 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(turn_file(session))
2332 {
2333 let _ = writeln!(f, "[{tool}] {touch}");
2334 if let Some(edit) = &edit {
2335 let _ = writeln!(f, "{edit}");
2336 }
2337 }
2338 (h.caps().tool_context && !subagent(input)).then(|| free_context(h, "PostToolUse", session, input, &touch)).flatten()
2342}
2343
2344fn subagent(input: &Value) -> bool {
2347 input.get("agent_id").and_then(Value::as_str).is_some_and(|a| !a.is_empty())
2348}
2349
2350struct StopLock {
2355 budget: Option<std::time::Duration>,
2356 guard: Option<live::Guard>,
2357}
2358
2359impl StopLock {
2360 fn take(h: &dyn Harness, session: &str) -> StopLock {
2361 StopLock { budget: crate::expert_route::stop_budget(h), guard: live::acquire(&state_file(session), learn::now_ms()) }
2362 }
2363}
2364
2365fn on_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2366 let started = std::time::Instant::now();
2367 let started_ms = learn::now_ms();
2368 if learning() {
2369 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
2371 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
2372 crate::sweep::turn_end(h, session, input);
2374 }
2375 let second_round = input.get("stop_hook_active").and_then(Value::as_bool) == Some(true);
2376 if std::env::var("BORG_CRITIC").as_deref() == Ok("generic") {
2377 return (!second_round).then(|| json!({"decision": "block", "reason": conscience::GENERIC}));
2378 }
2379 let (all, withheld) = loaded(session);
2380 let judged = judged(&all, withheld);
2381 note_answer(h, session, input);
2384 let changed = std::fs::remove_file(changes_file(session)).is_ok();
2387 if second_round {
2389 return None;
2390 }
2391 stat(session, input, crate::stats::TURN_END, |_| {});
2392 let lock = StopLock::take(h, session);
2393 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2394 let proposal = full_proposal(h, input);
2399 let acted = early_acted(session, &judged, &proposal);
2400 let only = std::env::var_os("BORG_ONLY").is_some();
2401 let done = if only { std::collections::HashSet::new() } else { done_in(&state, &acted) };
2402 let queue = if only { String::new() } else { std::fs::read_to_string(queued_file(session)).unwrap_or_default() };
2406 let waiting: std::collections::HashSet<&str> = if only { Default::default() } else { waiting_in(&queue).collect() };
2407 let ending: std::collections::HashSet<&str> = if lock.guard.is_some() { Default::default() } else { open_in(&state) };
2411 let challenges: Vec<conscience::Challenge> = all
2413 .iter()
2414 .filter(|c| !done.contains(c.id.as_str()) && !waiting.contains(c.id.as_str()) && !ending.contains(c.id.as_str()) && !c.off_system)
2415 .cloned()
2416 .collect();
2417 let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
2418 let context = format!("{state}{message}");
2419 let scope_text = format!("{}\n{message}", where_now(session));
2421 let check_id = crate::select::check_id(session, "stop", started_ms, std::process::id());
2422 let summary = crate::select::summary::refresh(&lessons_path());
2424 let work = Work {
2425 all: &all,
2426 judged: &judged,
2427 challenges: &challenges,
2428 done: &done,
2429 state: &state,
2430 context: &context,
2431 scope_text: &scope_text,
2432 message: &proposal,
2433 changed,
2434 judge: lock.guard.is_some(),
2435 budget: lock.budget,
2436 started,
2437 check: &check_id,
2438 summary: &summary,
2439 };
2440 let out = match stop_select(h, session, input, &work) {
2441 Ok(selected) => stop_ask(h, session, input, selected, &check_id),
2442 Err(out) => out,
2443 };
2444 drop(lock);
2445 out
2446}
2447
2448fn stop_select<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
2451 match std::env::var("BORG_ONLY") {
2452 Ok(ids) => Ok(w.challenges.iter().filter(|c| ids.split(',').any(|i| i == c.id)).collect()),
2454 Err(_) if std::env::var("BORG_MATCH").as_deref() != Ok("bm25") => stop_check(h, session, input, w),
2457 Err(_) => {
2460 let pool = conscience::budget_pool(&conscience::rank_scoped(w.all, &conscience::work_text(w.context), w.scope_text), w.done);
2461 let asked: Vec<&conscience::Challenge> = conscience::in_scope_first(conscience::explored(pool.iter().copied()))
2462 .into_iter()
2463 .filter_map(|c| w.challenges.iter().find(|x| x.id == c.id))
2464 .collect();
2465 append_p_of(session, &pool, &asked, w.check);
2466 Ok(asked)
2467 }
2468 }
2469}
2470
2471fn stop_check<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
2473 match check(session, input, w) {
2474 Verdict::Skip(why) => {
2475 append(session, &format!("#borg-skip {why}"));
2476 stat(session, input, crate::stats::GATE_SKIP, |e| e.why.clone_from(&why));
2477 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
2478 Err(crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &[]))
2479 }
2480 Verdict::Failed => Err(None),
2481 Verdict::Ask(ids) => Ok(ids.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).collect()),
2482 }
2483}
2484
2485fn graded_line(applied: &[(&conscience::Challenge, u8)]) -> String {
2488 let items: Vec<String> = applied.iter().enumerate().map(|(r, (c, g))| format!("{}:{g}:{r}", c.id)).collect();
2489 format!("#borg-graded n={} {}", applied.len(), items.join(","))
2490}
2491
2492fn p_line<'a>(prefix: &str, chosen: impl IntoIterator<Item = (&'a str, f64)>, check: Option<&str>) -> Option<String> {
2498 let items: Vec<String> = chosen.into_iter().map(|(id, p)| format!("{id}={}", outcome::round_p(p))).collect();
2499 (!items.is_empty()).then(|| format!("{prefix} {}{}", items.join(","), tag(check.unwrap_or_default())))
2500}
2501
2502fn append_p_of(session: &str, pool: &[(f64, &conscience::Challenge)], asked: &[&conscience::Challenge], check: &str) {
2509 if asked.is_empty() {
2510 return;
2511 }
2512 let p = conscience::propensity(pool, session);
2513 let chosen: Vec<(&str, f64)> = asked.iter().filter_map(|c| p.get(&c.id).map(|p| (c.id.as_str(), *p))).collect();
2514 let items: Vec<String> =
2515 pool.iter().map(|(s, c)| format!("{}={}{}", c.id, conscience::pool_base(*s, c), if c.scope.is_empty() { "" } else { ":s" })).collect();
2516 let pool = format!("#borg-pool {}{}", items.join(","), tag(check));
2517 append(session, &p_line("#borg-p", chosen, Some(check)).map_or(pool.clone(), |p| format!("{p}\n{pool}")));
2518}
2519
2520fn tagged(line: &str) -> (&str, Option<&str>) {
2522 match line.rsplit_once(" @") {
2523 Some((items, check)) if !check.is_empty() && check.bytes().all(|b| b.is_ascii_hexdigit()) => (items, Some(check)),
2524 _ => (line, None),
2525 }
2526}
2527
2528fn pool_in(state: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
2533 let parse = |line: &str| -> Option<(Vec<String>, Vec<f64>, Vec<bool>)> {
2534 let mut out = (Vec::new(), Vec::new(), Vec::new());
2535 for item in line.split(',') {
2536 let (id, rest) = item.rsplit_once('=')?;
2537 let (base, scoped) = match rest.strip_suffix(":s") {
2538 Some(b) => (b, true),
2539 None => (rest, false),
2540 };
2541 out.0.push(id.trim().to_string());
2542 out.1.push(base.trim().parse::<f64>().ok()?);
2543 out.2.push(scoped);
2544 }
2545 Some(out)
2546 };
2547 let mut check: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
2549 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
2550 let (items, tag) = tagged(line);
2551 for (id, _) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
2552 check.insert(id.trim(), tag);
2553 }
2554 }
2555 let Some(Some(tag)) = ids.first().and_then(|i| check.get(i.as_str())).copied() else { return Default::default() };
2556 if !ids.iter().all(|i| check.get(i.as_str()).copied().flatten() == Some(tag)) {
2557 return Default::default();
2558 }
2559 state
2560 .lines()
2561 .rev()
2562 .filter_map(|l| l.strip_prefix("#borg-pool "))
2563 .map(tagged)
2564 .filter(|(_, t)| *t == Some(tag))
2565 .find_map(|(items, _)| parse(items).filter(|(pool, _, _)| ids.iter().all(|i| pool.contains(i))))
2566 .unwrap_or_default()
2567}
2568
2569fn propensity_in(state: &str, ids: &[String]) -> Vec<f64> {
2573 let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
2574 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
2575 for (id, p) in tagged(line).0.split(',').filter_map(|it| it.rsplit_once('=')) {
2576 if let Ok(p) = p.trim().parse::<f64>() {
2577 seen.insert(id.trim(), p);
2578 }
2579 }
2580 }
2581 ids.iter().map(|i| seen.get(i.as_str()).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
2582}
2583
2584fn graded_of(session: &str, ids: &[String]) -> (Vec<u8>, Vec<u16>, u16) {
2587 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2588 let mut seen: std::collections::HashMap<String, (u8, u16, u16)> = std::collections::HashMap::new();
2589 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")) {
2590 let Some((n, items)) = line.split_once(' ') else { continue };
2591 let n: u16 = n.parse().unwrap_or(0);
2592 for it in items.split(',') {
2593 let mut p = it.rsplitn(3, ':');
2594 let (Some(r), Some(g), Some(id)) = (p.next(), p.next(), p.next()) else { continue };
2595 seen.insert(id.to_string(), (g.parse().unwrap_or(0), r.parse().unwrap_or(0), n));
2596 }
2597 }
2598 let got: Vec<(u8, u16, u16)> = ids.iter().map(|i| seen.get(i).copied().unwrap_or((0, 0, 0))).collect();
2599 (got.iter().map(|g| g.0).collect(), got.iter().map(|g| g.1).collect(), got.iter().map(|g| g.2).max().unwrap_or(0))
2600}
2601
2602fn record_asked(session: &str, input: &Value, lessons: &[&conscience::Challenge], channel: &str, check: &str) {
2607 if !learning() || lessons.is_empty() {
2608 return;
2609 }
2610 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2611 let ids: Vec<String> = lessons.iter().map(|c| c.id.clone()).collect();
2612 let members: Vec<&str> = ids.iter().map(String::as_str).collect();
2613 let (grades, ranks, applied) = graded_of(session, &ids);
2614 let p = if check.is_empty() { propensity_in(&state, &ids) } else { tagged_p(&state, "#borg-p ", check, &members) };
2615 let (pool, base, scoped) = pool_in(&state, &ids);
2616 let asked = outcome::Outcome {
2617 kind: outcome::ASKED.into(),
2618 session: session.into(),
2619 lessons: ids.clone(),
2620 ms: learn::now_ms(),
2621 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
2622 groups: conscience::disagreement_ids(lessons),
2624 channel: channel.into(),
2625 grades,
2626 ranks,
2627 applied,
2628 p,
2629 pool,
2630 base,
2631 scoped,
2632 check: check.into(),
2633 ..Default::default()
2634 };
2635 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
2636 record_route(session, input, check, ids, channel, outcome::ROUTE_ASKED, "");
2637}
2638
2639fn stop_ask(h: &dyn Harness, session: &str, input: &Value, mut lessons: Vec<&conscience::Challenge>, check: &str) -> Option<Value> {
2642 crate::memory::shuffle(&mut lessons, session);
2645 let log = lessons.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
2646 let marker = if lessons.is_empty() { "#borg-asked ".to_string() } else { format!("#borg-asked {log}{}", tag(check)) };
2648 append(session, &marker);
2649 if !lessons.is_empty()
2650 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
2651 {
2652 let _ = writeln!(f, "{}\n{marker}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
2654 }
2655 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
2658 let queen = crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &lessons);
2659 if lessons.is_empty() {
2660 return queen;
2661 }
2662 record_asked(session, input, &lessons, "stop", check);
2663 let out = queen.or_else(|| Some(json!({"decision": "block", "reason": conscience::render(&lessons)})));
2664 let chars = out.as_ref().and_then(|o| o["reason"].as_str()).map_or(0, str::len);
2665 log_cost(session, &format!("block questions={} chars={chars}", lessons.len()));
2666 stat(session, input, crate::stats::BLOCK, |e| {
2667 e.n = lessons.len() as u64;
2668 e.lessons = lessons.iter().map(|c| c.id.clone()).collect();
2669 e.channel = "stop".into();
2670 });
2671 out
2672}
2673
2674pub fn main(name: &str, event: &str) {
2679 let Some(h) = harness::by_name(name) else { return };
2680 let mut raw = String::new();
2681 let _ = std::io::stdin().read_to_string(&mut raw);
2682 let mut input = h.normalise(serde_json::from_str(&raw).unwrap_or(Value::Null));
2683 if !h.caps().sends_pid
2685 && let Some(m) = input.as_object_mut()
2686 {
2687 m.shift_remove("pid");
2688 }
2689 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2692 let cfg = collective::load();
2693 let route = match &cfg {
2694 Ok(cfg) => collective::route(cfg.as_ref(), cwd, &learn::home()),
2695 Err(_) => collective::Route::Off,
2696 };
2697 let collective::Route::Collective { home, name, .. } = route else { return };
2698 unsafe {
2701 std::env::set_var("BORG_HOME", &home);
2702 std::env::set_var("BORG_HARNESS", h.name());
2704 if std::env::var_os("BORG_STATE").is_none() {
2705 std::env::set_var("BORG_STATE", home.join("state"));
2706 }
2707 }
2708 let now = learn::now_ms();
2710 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2711 crate::listen::touch(&home, event, session, now);
2715 if matches!(event, "start" | "halted") {
2717 return;
2718 }
2719 let me = match event {
2722 "pre_tool" => {
2723 if !crate::steer::active(&home) {
2724 return;
2725 }
2726 crate::presence::read(&home, session)
2727 }
2728 _ => crate::presence::touch(&home, &name, h.name(), event, &input, now),
2729 };
2730 let started = me.map(|p| p.started);
2733 let stopping = crate::listen::stopping(&home, event, session, now);
2735 let out = crate::steer::around(&home, event, &input, started, now, || crate::expert_route::checkpoint(h, event, &input, || dispatch(h, event, &input)));
2736 let out = crate::listen::after_stop(&home, h, stopping.as_ref(), &input, out);
2738 let out = harness::with_budgets(h, &input, out);
2740 if let Some(out) = crate::msg::deliver(&home, h.caps(), event, &input, started, now, out) {
2742 println!("{}", for_harness(h, out));
2743 }
2744}
2745
2746fn for_harness(h: &dyn Harness, mut out: Value) -> Value {
2750 if matches!(h.wiring(), harness::Wiring::CommandHooks { .. })
2751 && let Some(o) = out.as_object_mut()
2752 {
2753 o.shift_remove("shown_ids");
2754 }
2755 out
2756}
2757
2758#[cfg(test)]
2759mod tests {
2760 use super::*;
2761
2762 #[test]
2763 fn every_file_of_an_edit_survives_the_cut_of_its_text() {
2764 let long = "x".repeat(700);
2765 let input = json!({"file_path": "/w/a.rs", "new_string": long, "edits": [{"file_path": "/w/b.rs", "new_string": "more"}]});
2766 let text = edit_text("Edit", &input).unwrap();
2767 assert!(text.starts_with("/w/a.rs /w/b.rs x"), "{}", &text[..40]);
2768 assert_eq!(text.chars().count(), 600);
2769 let multi = json!({"file_path": "/w/a.rs", "edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "c", "new_string": "d"}]});
2770 assert_eq!(edit_text("MultiEdit", &multi).as_deref(), Some("/w/a.rs b d"), "Claude's MultiEdit reads as before");
2771 assert_eq!(touched(&input, "Edit"), "/w/a.rs /w/b.rs");
2772 }
2773
2774 #[test]
2775 fn propensity_in_takes_the_latest_entry_and_nothing_when_one_is_missing() {
2776 let state = "#borg-p A=1,B=0.25\n#borg-asked A,B\n#borg-p B=0.5,C=0.1\n";
2777 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2778 assert_eq!(propensity_in(state, &ids(&["B", "A"])), [0.5, 1.0]);
2779 assert_eq!(propensity_in(state, &ids(&["C"])), [0.1]);
2780 assert!(propensity_in(state, &ids(&["A", "D"])).is_empty(), "D unknown: no guess");
2781 assert!(propensity_in("", &ids(&["A"])).is_empty());
2782 }
2783
2784 #[test]
2785 fn pool_in_takes_the_pool_of_the_check_that_logged_each_p() {
2786 let state = "#borg-p A=0.6,B=0.4 @c1\n#borg-pool A=1.5:s,B=0.25,C=2 @c1\n#borg-asked A,B\n#borg-p B=0.5 @c2\n#borg-pool B=0.5,D=0.125:s @c2\n";
2787 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2788 assert_eq!(pool_in(state, &ids(&["B"])), (ids(&["B", "D"]), vec![0.5, 0.125], vec![false, true]), "B's latest p is check 2's");
2789 assert_eq!(pool_in(state, &ids(&["A"])), (ids(&["A", "B", "C"]), vec![1.5, 0.25, 2.0], vec![true, false, false]));
2790 assert_eq!(pool_in(state, &ids(&["A", "B"])), Default::default(), "A's p from check 1, B's from check 2: no pairing");
2791 assert_eq!(pool_in("", &ids(&["A"])), Default::default());
2792 assert_eq!(pool_in(state, &[]), Default::default());
2793 let graded = "#borg-p A=1\n#borg-p B=0.5 @c3\n#borg-pool A=1,B=1 @c3\n";
2795 assert_eq!(pool_in(graded, &ids(&["A"])), Default::default());
2796 assert_eq!(pool_in(graded, &ids(&["B"])).0, ids(&["A", "B"]));
2797 assert_eq!(pool_in("#borg-p A=1\n#borg-pool A=1,B=2\n", &ids(&["A"])), Default::default());
2799 }
2800
2801 #[test]
2802 fn a_queue_line_names_its_route_its_ids_and_its_check() {
2803 let c = "0123456789ab";
2804 assert_eq!(queue_line(&format!("A,B @{c}")), (Queued::Free, vec!["A", "B"], c));
2805 assert_eq!(queue_line(&format!("placebo X,Y @{c}")), (Queued::Placebo, vec!["X", "Y"], c));
2806 assert_eq!(queue_line(&format!("instead G0,G1 @{c}")), (Queued::Instead, vec!["G0", "G1"], c));
2807 assert_eq!(queue_line(&format!("instead-next G2 @{c}")), (Queued::Instead, vec!["G2"], c));
2808 assert_eq!(queue_line("A,B"), (Queued::Free, vec!["A", "B"], ""));
2810 assert_eq!(queue_line(&format!("live A @{c}")), (Queued::Ask, vec!["A"], c));
2812 assert_eq!(queue_line("live A"), (Queued::Ask, vec!["A"], ""));
2813 assert_eq!(queue_line(&format!("live-next A,B @{c}")), (Queued::Ask, vec!["A", "B"], c));
2814 assert_eq!(listed(&format!("#borg-asked A,B @{c}\n#borg-asked C\n"), "#borg-asked "), ["A", "B", "C"].into_iter().collect());
2815 }
2816
2817 #[test]
2818 fn a_tagged_propensity_is_its_checks_never_another_checks() {
2819 let state = "#borg-pf A=1,B=0.5 @c1\n#borg-pf A=0.25 @c2\n#borg-p A=0.75 @c1\n#borg-pf B=0.9\n";
2820 assert_eq!(tagged_p(state, "#borg-pf ", "c1", &["A", "B"]), [1.0, 0.5]);
2821 assert_eq!(tagged_p(state, "#borg-pf ", "c2", &["A"]), [0.25]);
2822 assert!(tagged_p(state, "#borg-pf ", "c2", &["A", "B"]).is_empty(), "B has none at c2: no guess");
2823 assert_eq!(tagged_p(state, "#borg-p ", "c1", &["A"]), [0.75], "#borg-p and #borg-pf are separate lines");
2824 assert_eq!(p_line("#borg-pf", [("A", 1.0), ("B", 0.33333)], Some("c1")).as_deref(), Some("#borg-pf A=1,B=0.3333 @c1"));
2825 assert_eq!(p_line("#borg-p", [], Some("c1")), None);
2826 }
2827
2828 #[test]
2829 fn unjudged_says_why_each_delivered_lesson_has_no_verdict() {
2830 let c = "0123456789ab";
2831 let state = format!(
2832 "#borg-asked A,B,C,D @{c}\n#borg-judged A=pending\n#borg-unjudged B why=slots\n#borg-judged A=pending\n#borg-unjudged D why=failed\n\
2833 #borg-queued E @{c}\n#borg-judged B=pending\n#borg-asked D\n"
2834 );
2835 let why = unjudged(&state);
2836 assert_eq!(why["A"], outcome::UNJUDGED_TRIES, "two unclear tries");
2837 assert_eq!(why["B"], outcome::UNJUDGED_SLOTS, "one try is not all of them; the last call left it out");
2838 assert_eq!(why["C"], outcome::UNJUDGED_NO_CALL);
2839 assert_eq!(why["D"], outcome::UNJUDGED_NO_CALL, "delivered again after its failed call");
2840 assert_eq!(why["E"], outcome::UNJUDGED_NO_CALL);
2841 }
2842
2843 #[test]
2844 fn the_declaration_coin_is_fixed_per_check_and_lesson_and_fair() {
2845 assert_eq!(stripped("0123456789ab", "L1"), stripped("0123456789ab", "L1"));
2846 let heads = (0..1000).filter(|i| stripped("0123456789ab", &format!("L{i}"))).count();
2847 assert!((400..600).contains(&heads), "{heads} of 1000");
2848 let checks = (0..1000).filter(|i| stripped(&format!("{i:012x}"), "L1")).count();
2849 assert!((400..600).contains(&checks), "{checks} of 1000");
2850 }
2851
2852 #[test]
2857 fn pending_gives_every_route_the_same_evidence_and_its_own_check() {
2858 let session = format!("pending-v2-{}", std::process::id());
2859 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2860 let all = [c("A"), c("B"), c("C"), c("D"), c("E")];
2861 let edits = "Plan: the blocked message\n#borg-asked A,B @0000000000c1\n#borg-routed B=0000000000c2\n#borg-answer A: applies. B: skip.\n\
2862 edit one\n#borg-work the work so far\n#borg-queued C @0000000000c3\n#borg-placebo D @0000000000c4\nedit two\n\
2863 #borg-work later work\n#borg-asked E @0000000000c5\n";
2864 let _ = std::fs::write(edits_file(&session), edits);
2865 let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
2866 let j = pending(&session, &state, &all, "the final message", When::Stop, "00000000cafe");
2867 let got: Vec<(&str, &str, &str)> = j.pending.iter().map(|p| (p.lesson.id.as_str(), p.channel, p.check.as_str())).collect();
2868 assert_eq!(
2869 got,
2870 [
2871 ("A", "stop", "0000000000c1"),
2872 ("B", "stop", "0000000000c2"),
2873 ("C", "queued", "0000000000c3"),
2874 ("D", outcome::PLACEBO, "0000000000c4"),
2875 ("E", "stop", "0000000000c5")
2876 ]
2877 );
2878 let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
2879 assert_eq!(
2880 (p("A").before.as_str(), p("C").before.as_str(), p("D").before.as_str()),
2881 ("Plan: the blocked message", "the work so far", "the work so far")
2882 );
2883 assert_eq!(p("C").after, "work since: edit two\nthis turn: the final message");
2884 assert_eq!(p("D").after, p("C").after, "a placebo is judged as a free delivery");
2885 assert!(p("A").after.starts_with("work since: ") && p("A").after.contains("edit one | edit two"), "{}", p("A").after);
2886 assert_eq!(p("A").after, p("B").after, "delivered together, one shared evidence");
2887 for (id, word) in [("A", "applies"), ("B", "skip")] {
2888 let x = p(id);
2889 assert_eq!(x.stripped, stripped(&x.check, id));
2890 match x.stripped {
2891 true => assert!(x.declared.is_none() && !x.after.contains(&format!("{id}: {word}")), "{x:?}"),
2892 false => assert!(x.declared.is_some() && x.after.contains(&format!("{id}: {word}")), "{x:?}"),
2893 }
2894 }
2895 assert!(!p("C").stripped && !p("D").stripped, "only questions are stripped");
2896 assert!(j.question && j.crowded.is_empty());
2897 let _ = std::fs::remove_file(edits_file(&session));
2898 }
2899
2900 #[test]
2904 fn a_live_question_from_before_the_upgrade_is_still_its_lessons_latest_delivery() {
2905 let session = format!("pending-later-{}", std::process::id());
2906 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2907 let all = [c("L"), c("M")];
2908 let (a, b) = ("00000000000a", "00000000000b");
2909 let asked = format!("#borg-work so far\n#borg-queued L,M @{a}\nedit one\n#borg-work more\n#borg-asked L @{b}\n#borg-live L ms=1\nedit two\n");
2910 let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
2911 let answered = format!("{asked}#borg-answer L: applies\n");
2912 for edits in [&asked, &answered] {
2913 let _ = std::fs::write(edits_file(&session), edits);
2914 for when in [When::Stop, When::End] {
2915 let j = pending(&session, &state_of(edits), &all, "", when, "00000000cafe");
2916 let got: Vec<(String, &str, String)> = j.pending.iter().map(|p| (p.lesson.id.clone(), p.channel, p.check.clone())).collect();
2917 assert_eq!(got, [("M".to_string(), "queued", a.to_string()), ("L".to_string(), "live", b.to_string())], "{when:?}: {edits}");
2918 }
2919 }
2920 let _ = std::fs::remove_file(edits_file(&session));
2921 }
2922
2923 #[test]
2926 fn a_free_delivery_or_placebo_is_judged_at_the_turn_end() {
2927 let session = format!("pending-work-{}", std::process::id());
2928 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2929 let all = [c("A"), c("C"), c("D"), c("E")];
2930 let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
2931 let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
2932 let fresh = "Plan: blocked\n#borg-asked A @0000000000c1\n#borg-answer A: applies\n#borg-work so far\n#borg-queued C @0000000000c3\n#borg-placebo D @0000000000c4\n";
2933 for edits in [fresh.to_string(), format!("{fresh}edit one\n"), format!("{fresh}edit one\n#borg-work later\n#borg-early E @0000000000c5\nedit two\n")] {
2934 let _ = std::fs::write(edits_file(&session), &edits);
2935 let state = state_of(&edits);
2936 let early = edits.contains("#borg-early ");
2937 for when in [When::Stop, When::End] {
2938 let want: &[&str] = if early { &["A", "C", "D", "E"] } else { &["A", "C", "D"] };
2939 assert_eq!(ids(&pending(&session, &state, &all, "", when, "00000000cafe")), want, "{when:?}: {edits}");
2940 }
2941 }
2942 let _ = std::fs::remove_file(edits_file(&session));
2943 }
2944
2945 #[test]
2950 fn the_session_end_judge_never_outlives_its_lock() {
2951 let (end, stale) = (end_timeout(), live::stale_ms());
2952 assert!(i64::try_from(end.as_millis()).unwrap() < stale, "{end:?} against a lock stale after {stale} ms");
2953 assert!(end <= learn::learn_timeout());
2954 }
2955
2956 #[test]
2957 fn the_session_end_judge_takes_every_open_delivery_once() {
2958 let session = format!("pending-end-{}", std::process::id());
2959 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2960 let all = [c("A"), c("B"), c("C"), c("D"), c("Q")];
2961 let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
2962 let edits = "Plan\n#borg-asked A,B @0000000000c1\n#borg-answer A: applies\nedit\n#borg-work w\n#borg-queued C,D @0000000000c3\nedit two\nPlan two\n#borg-asked Q @0000000000c4\n";
2963 let _ = std::fs::write(edits_file(&session), edits);
2964 let delivered: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
2965 let state = format!("{delivered}#borg-judged A=changed\n#borg-judged B=pending\n#borg-judged C=pending\n#borg-judged B=pending\n");
2967 assert_eq!(ids(&pending(&session, &state, &all, "", When::Stop, "00000000cafe")), ["C", "D", "Q"], "a check: B's tries are spent");
2968 assert_eq!(ids(&pending(&session, &state, &all, "", When::End, "00000000cafe")), ["B", "C", "D", "Q"], "the end: every open one");
2969 let ended = format!("{state}#borg-judged B=pending\n#borg-judged C=changed\n{END_JUDGED}B,C,D,Q\n");
2970 for when in [When::Stop, When::End] {
2971 assert!(pending(&session, &ended, &all, "", when, "00000000cafe").pending.is_empty(), "{when:?}: judged at the end once");
2972 }
2973 let why = unjudged(&ended);
2974 assert_eq!((why["B"], why["D"], why["Q"]), (outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES));
2975 let again = format!("{ended}#borg-queued D @0000000000c5\n");
2977 let _ = std::fs::write(edits_file(&session), format!("{edits}#borg-queued D @0000000000c5\n"));
2978 assert_eq!(ids(&pending(&session, &again, &all, "", When::End, "00000000cafe")), ["D"]);
2979 assert_eq!(unjudged(&again)["D"], outcome::UNJUDGED_NO_CALL);
2980 let _ = std::fs::remove_file(edits_file(&session));
2981 }
2982
2983 fn source(label: &'static str, text: &str, marks: &[(usize, &str)]) -> Source {
2985 Source { label, text: text.chars().collect(), marks: marks.iter().map(|(at, id)| (*at, vec![id.to_string()])).collect() }
2986 }
2987
2988 #[test]
2992 fn the_session_work_shares_its_budget_and_is_read_whole_when_it_fits() {
2993 let parts = [source("short", "abc", &[]), source("long", "0123456789", &[]), source("none", "", &[])];
2994 let none = [None, None, None];
2995 let whole = "### short\nabc\n### long\n0123456789\n";
2996 assert_eq!(within(&parts, &none, 100, Cut::After), (whole.to_string(), false));
2997 assert_eq!(within(&parts, &none, whole.chars().count(), Cut::After), (whole.to_string(), false), "exactly fits: whole");
2998 assert_eq!(within(&parts, &none, 33, Cut::Newest), ("### short\nabc\n### long\n…56789\n".to_string(), true), "short keeps its 3, long the other 5");
3000 assert_eq!(within(&parts, &none, 29, Cut::After), ("### short\n…bc\n### long\n…89\n".to_string(), true), "two each when neither fits its share");
3001 for budget in 0..60 {
3002 for cut in [Cut::Newest, Cut::After, Cut::Ends] {
3003 let (text, _) = within(&parts, &[None, Some(3), None], budget, cut);
3004 assert!(text.chars().count() <= budget, "{budget} {cut:?}: {text:?}");
3005 }
3006 }
3007 }
3008
3009 #[test]
3013 fn a_long_sessions_work_is_cut_at_the_batchs_earliest_delivery() {
3014 let text: Vec<char> = "0123456789abcdefghij".chars().collect();
3015 assert_eq!(Cut::Newest.window(&text, 6, Some(4)), "…efghij");
3016 assert_eq!(Cut::After.window(&text, 6, Some(4)), "…456789…", "from the delivery on, its start first");
3017 assert_eq!(Cut::After.window(&text, 6, Some(0)), "012345…");
3018 assert_eq!(Cut::Ends.window(&text, 6, Some(4)), "…456…hij", "half after the delivery, half the end");
3019 assert_eq!(Cut::After.window(&text, 6, Some(16)), "…efghij", "what follows fits: the newest");
3020 assert_eq!(Cut::Ends.window(&text, 6, Some(15)), "…efghij", "the halves meet: the newest");
3021 for cut in [Cut::Newest, Cut::After, Cut::Ends] {
3022 assert_eq!(cut.window(&text, 6, None), "…efghij", "{cut:?}: no delivery in it");
3023 }
3024 let parts = [source("edits", "0123456789abcdefghij", &[(4, "L")]), source("said", "ABCDEFGHIJKLMNOPQRST", &[])];
3026 let (work, cut) = within(&parts, &[parts[0].anchor(&["L"]), parts[1].anchor(&["L"])], 34, Cut::After);
3027 assert!(cut);
3028 assert_eq!(work, "### edits\n…4567…\n### said\n…PQRST\n");
3029 }
3030
3031 #[test]
3035 fn a_source_marks_where_each_delivery_stood_without_stripped_declarations() {
3036 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3037 let (a, f) = (c("A"), c("F"));
3038 let p = |lesson, stripped| predict::Pending {
3039 lesson,
3040 before: String::new(),
3041 after: String::new(),
3042 declared: None,
3043 check: String::new(),
3044 channel: "stop",
3045 stripped,
3046 };
3047 let pend = [p(&a, false), p(&f, true)];
3048 let edits = "one\n#borg-work w\n#borg-asked A,F @0000000000c1\n#borg-answer A: applies. F: applies.\ntwo\n#borg-queued A @0000000000c2\nthree\n";
3049 let s = Source::of("edits", edits, |l| l.strip_prefix("#borg-answer ").or_else(|| (!l.starts_with("#borg-")).then_some(l)), &pend);
3050 let text: String = s.text.iter().collect();
3051 assert_eq!(text, "one\nA: applies. .\ntwo\nthree");
3052 assert_eq!((s.anchor(&["F"]), s.anchor(&["A"]), s.anchor(&["A", "F"]), s.anchor(&["Z"])), (Some(4), Some(22), Some(4), None));
3053 assert!(Source::of("blank", "#borg-asked A\n \n", Some, &pend).text.is_empty(), "only whitespace: empty");
3054 }
3055
3056 #[test]
3060 fn a_pass_explores_the_step_above_on_its_logged_chance() {
3061 let d = std::env::temp_dir().join(format!("borg-end-budget-{}", std::process::id()));
3062 let _ = std::fs::remove_dir_all(&d);
3063 std::fs::create_dir_all(&d).unwrap();
3064 let draws: Vec<EndBudget> = (0..500).map(|i| end_budget(&d, "m:x", &format!("check-{i}"))).collect();
3065 let psi = crate::judge_length::EXPLORE_SHARE;
3066 for b in &draws {
3067 let explored = b.draw.expect("drawn, logged").1;
3068 assert_eq!(*b, EndBudget { chars: if explored { 108_000 } else { END_START_BUDGET }, draw: Some((psi, explored)) });
3069 }
3070 let share = draws.iter().filter(|b| b.draw.is_some_and(|d| d.1)).count() as f64 / draws.len() as f64;
3071 assert!((share - psi).abs() < 0.06, "explored {share} of passes at chance {psi}");
3072 assert_eq!(end_budget(&d, "m:x", "check-7"), draws[7], "the same seed, the same draw");
3073 let line = |chars: usize, n: u64, a: u64| {
3075 format!("judge call=1 pending={n} resolved={a} chars={chars} verdicts=changed:{a},unclear:{} model=m:x at=end\n", n - a).repeat(10)
3076 };
3077 std::fs::write(d.join("s.cost"), line(30_000, 40, 20) + &line(100_000, 40, 5)).unwrap();
3078 let floor = crate::judge_length::FAILED_EXPLORE_SHARE;
3079 let draws: Vec<EndBudget> = (0..2000).map(|i| end_budget(&d, "m:x", &format!("check-{i}"))).collect();
3080 for b in &draws {
3081 let explored = b.draw.expect("drawn, logged").1;
3082 assert_eq!(*b, EndBudget { chars: if explored { 108_000 } else { END_START_BUDGET }, draw: Some((floor, explored)) });
3083 }
3084 let share = draws.iter().filter(|b| b.draw.is_some_and(|d| d.1)).count() as f64 / draws.len() as f64;
3085 assert!((share - floor).abs() < 0.02, "explored {share} of passes at chance {floor}");
3086 let _ = std::fs::remove_dir_all(&d);
3087 }
3088
3089 #[test]
3093 fn every_lesson_lands_in_one_batch_whose_prompt_stays_within_the_budget() {
3094 let lessons: Vec<conscience::Challenge> = (0..278)
3095 .map(|i| conscience::Challenge {
3096 id: format!("L{i:03}"),
3097 question: format!("Question {i} {}", "q".repeat(i * 7 % 400)),
3098 consequence: format!("consequence {i}"),
3099 ..Default::default()
3100 })
3101 .collect();
3102 let pend: Vec<predict::Pending> = lessons
3103 .iter()
3104 .enumerate()
3105 .map(|(i, l)| predict::Pending {
3106 lesson: l,
3107 before: format!("before {} {}", i / 3, "b".repeat(i * 13 % 1300)),
3109 after: format!("after {} {}", i / 3, "a".repeat(i * 31 % 4000)),
3110 declared: None,
3111 check: String::new(),
3112 channel: "queued",
3113 stripped: false,
3114 })
3115 .collect();
3116 let long: String = (0..4000).map(|i| format!("edit {i:04} of the long session\n")).collect();
3117 let marks: Vec<(usize, &str)> = lessons.iter().enumerate().map(|(i, l)| (i * 100, l.id.as_str())).collect();
3118 let sources = [source("prompts", &"p".repeat(30_000), &[]), source("edits", &long, &marks), source("said", "", &[])];
3119 for budget in [END_START_BUDGET, 20_000, 200_000] {
3120 let groups = batches(&pend, budget);
3121 assert!(groups.len() > 1, "{budget}: {groups:?}");
3122 assert_eq!(groups.iter().flat_map(Clone::clone).collect::<Vec<_>>(), (0..pend.len()).collect::<Vec<_>>(), "each once, in order");
3123 for g in &groups {
3124 let batch = &pend[g.clone()];
3125 assert!(batch.len() == 1 || predict::judge_section(batch).chars().count() <= budget / END_LESSONS_SHARE, "{budget}: {g:?}");
3126 let fixed = predict::judge_prompt("", batch).chars().count();
3127 let ids: Vec<&str> = batch.iter().map(|p| p.lesson.id.as_str()).collect();
3128 let anchors: Vec<Option<usize>> = sources.iter().map(|s| s.anchor(&ids)).collect();
3129 let (work, cut) = within(&sources, &anchors, budget.saturating_sub(fixed), Cut::After);
3130 assert!(cut, "a long session is cut");
3131 assert!(predict::judge_prompt(&work, batch).chars().count() <= budget, "{budget}: {g:?}");
3132 let first = g.start * 100;
3134 let body = work.split("### edits\n").nth(1).unwrap().trim_end_matches('\n');
3135 let body = body.strip_prefix('…').unwrap_or(body).trim_end_matches('…');
3136 let at = long.find(body).unwrap();
3137 assert!(at == first || (at <= first && at + body.len() + 1 >= long.len()), "{budget}: {g:?} at {at}");
3138 }
3139 assert!(batches(&pend[..3], 100).iter().all(|g| g.len() == 1));
3141 }
3142 }
3143
3144 #[test]
3147 fn the_judge_cost_line_names_the_returned_and_unmatched_ids() {
3148 fn p(lesson: &conscience::Challenge) -> predict::Pending<'_> {
3149 predict::Pending { lesson, before: String::new(), after: String::new(), declared: None, check: String::new(), channel: "stop", stripped: false }
3150 }
3151 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3152 let (a, b) = (c("La"), c("Lb"));
3153 let pend = [p(&a), p(&b)];
3154 let returned = ["La".to_string(), "Lz".to_string()];
3155 let j = |id: &str, verdict, reason: &str| predict::Judged { id: id.into(), verdict, reason: reason.into() };
3156 let one = [j("La", predict::Verdict::NotArisen, "only docs"), j("Lz", predict::Verdict::Changed, "not pending")];
3157 assert_eq!(
3158 judge_line(3, &pend, &one, 900, &returned),
3159 "judge call=3 pending=2 resolved=1 chars=900 returned=La,Lz unmatched=Lz \
3160 verdicts=changed:0,ignored:0,not_arisen:1,consequence_remains:0,unclear:0 reason_chars=9"
3161 );
3162 let pair = [j("La", predict::Verdict::Unclear, ""), j("Lb", predict::Verdict::ConsequenceRemains, "still awaits")];
3163 assert_eq!(
3164 judge_line(4, &pend, &pair, 10, &[]),
3165 "judge call=4 pending=2 resolved=0 chars=10 returned= unmatched= \
3166 verdicts=changed:0,ignored:0,not_arisen:0,consequence_remains:1,unclear:1 reason_chars=12"
3167 );
3168 }
3169
3170 #[test]
3175 fn a_stripped_declaration_leaves_every_evidence_of_the_call() {
3176 let session = format!("pending-strip-{}", std::process::id());
3177 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3178 let all = [c("A"), c("F"), c("G")];
3179 let check = |lesson: &str, strip: bool| (0u64..).map(|i| format!("{i:012x}")).find(|k| stripped(k, lesson) == strip).unwrap();
3180 let (ca, cf, cg) = (check("A", false), check("F", true), check("G", false));
3181 let answer = "Fixed the arc. F: applies";
3182 let edits = format!(
3183 "Plan: the blocked message\n#borg-asked A @{ca}\n#borg-answer A: applies\nedit one\n#borg-work the work so far\n\
3184 #borg-asked F @{cf}\n#borg-live F ms=1\n#borg-answer {answer}\n{answer}\n#borg-asked G @{cg}\n"
3185 );
3186 let _ = std::fs::write(edits_file(&session), &edits);
3187 let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
3188 let j = pending(&session, &state, &all, answer, When::Stop, "00000000cafe");
3189 let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
3190 assert!(p("F").stripped && p("F").declared.is_none(), "{:?}", p("F"));
3191 for x in &j.pending {
3192 assert!(!format!("{} {}", x.before, x.after).to_lowercase().contains("f: applies"), "{x:?}");
3193 }
3194 assert!(p("F").after.contains("this turn: Fixed the arc."), "the rest of the message stays: {}", p("F").after);
3195 assert!(!p("A").stripped && p("A").declared == Some(true) && p("A").after.contains("A: applies"), "{:?}", p("A"));
3196 assert_eq!(without_declarations(&format!("work\n{answer}"), &j.pending), "work\nFixed the arc. ");
3198 assert_eq!(without_declarations("A: applies", &j.pending), "A: applies");
3199 let _ = std::fs::remove_file(edits_file(&session));
3200 }
3201
3202 #[test]
3207 fn a_merged_ask_of_two_draws_carries_no_pool() {
3208 let session = format!("pool-merge-{}", std::process::id());
3209 let _ = std::fs::remove_file(state_file(&session));
3210 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3211 let (a, b, cc, d, e, f) = (c("A"), c("B"), c("C"), c("D"), c("E"), c("F"));
3212 let live: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &b), (0.8, &cc), (0.7, &d)];
3213 append_p_of(&session, &live, &[&a, &b], "c1");
3214 let stop: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &e), (0.8, &f)];
3215 append_p_of(&session, &stop, &[&a], "c2");
3216 let state = std::fs::read_to_string(state_file(&session)).unwrap_or_default();
3217 let merged = vec!["A".to_string(), "B".to_string()];
3218 assert_eq!(propensity_in(&state, &merged).len(), 2, "each id has its latest p");
3219 assert_eq!(pool_in(&state, &merged), Default::default());
3220 assert_eq!(pool_in(&state, &["A".to_string()]).0, ["A", "E", "F"], "A alone: the stop check's pool");
3221 assert_eq!(pool_in(&state, &["B".to_string()]).0, ["A", "B", "C", "D"], "B alone: the live check's");
3222 assert_eq!(tagged_p(&state, "#borg-p ", "c1", &["B"]).len(), 1, "B's record carries its own check's p");
3223 let _ = std::fs::remove_file(state_file(&session));
3224 }
3225
3226 #[test]
3230 fn an_early_lessons_relative_score_is_its_share_of_the_best_on_topic_match() {
3231 let c = |id: &str, q: &str| conscience::Challenge { id: id.into(), question: q.into(), scope: vec!["Orion".into()], ..Default::default() };
3232 let standing = conscience::Challenge { source: conscience::CONSTRAINT.into(), ..c("S", "harpsichord tuning") };
3233 let all = vec![c("A", "orion budget place reset arc drain"), c("B", "orion budget place"), c("C", "telescope glaciers"), standing];
3234 let work = "drain the orion budget place, never with a reset arc";
3235 let topics = vec!["Orion".to_string()];
3236 let ranked = conscience::rank(&all, work);
3237 let score = |id: &str| ranked.iter().find(|(_, x)| x.id == id).map_or(0.0, |(s, _)| *s);
3238 assert!(score("A") > score("B") && score("B") > 0.0, "{ranked:?}");
3239 let rel = early_rel(&all, work, &topics, &[&all[1], &all[3]]);
3241 assert_eq!(rel, [outcome::round_p(score("B") / score("A")), 0.0]);
3242 assert!(rel[0] < 1.0);
3243 assert_eq!(early_rel(&all, work, &topics, &[&all[0]]), [1.0]);
3244 assert!(early_rel(&all, work, &["Beacon".to_string()], &[&all[3]]).is_empty());
3246 }
3247
3248 fn decide_store(name: &str) -> std::path::PathBuf {
3252 decide_store_with(name, true)
3253 }
3254
3255 fn decide_store_with(name: &str, sure: bool) -> std::path::PathBuf {
3259 use crate::select::{policy, summary};
3260 let d = std::env::temp_dir().join(format!("borg-decide-{name}-{}", std::process::id()));
3261 let _ = std::fs::remove_dir_all(&d);
3262 std::fs::create_dir_all(&d).unwrap();
3263 let lessons = d.join("lessons.jsonl");
3264 std::fs::write(&lessons, "").unwrap();
3265 std::fs::write(outcome::path_beside(&lessons), "").unwrap();
3266 let at = |name: &str| policy::NAMES.iter().position(|n| *n == name).unwrap();
3267 let mut beta = vec![0.0; policy::P];
3268 beta[at("intercept")] = -0.5;
3269 if sure {
3270 beta[at("ask")] = -3.0;
3271 beta[at("not")] = -1.0;
3272 }
3273 let mut chol = vec![0.0; policy::P * policy::P];
3274 for k in 0..policy::P {
3275 chol[k * policy::P + k] = 0.05;
3276 }
3277 let model = policy::Model { beta, chol, sd: vec![1.0; policy::P], spread: [0.1; 3], ..policy::Model::default() };
3278 let rates = policy::Rates { free_stop: 1.0, free_live: 1.0, later: 0.0, wait: 0.0, ask_live: 1.0 };
3279 let fit = policy::Fit { key: "9-abcdef01".into(), model: Some(model), rates, ..policy::Fit::default() };
3280 let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: 5.0, ..summary::Gate::default() };
3281 summary::save(&lessons, &summary::Summary { fit, gate, ..summary::Summary::default() }).unwrap();
3282 lessons
3283 }
3284
3285 fn record_beside(lessons: &std::path::Path, applied: &[(&conscience::Challenge, u8)], check: &str, r: &crate::select::Routed, basis: &Basis) -> bool {
3288 let order: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
3289 let call = Call { eligible: order.len(), order: &order, tail: &[], pt: 0.0, budget: 0, model: "", prompt_hash: String::new(), dur_ms: 1, usd: 0.0 };
3290 let o = graded_outcome("decide-s", "r", check, applied, Some((r, basis)), basis.summary, &call);
3291 outcome::append(&outcome::path_beside(lessons), &o).is_ok()
3292 }
3293
3294 #[test]
3297 fn the_graded_record_carries_the_question_count_tests_draw() {
3298 use crate::select::{Log, Routed, policy, summary};
3299 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3300 let ls = [c("A"), c("B")];
3301 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([2, 1]).collect();
3302 let order: Vec<&conscience::Challenge> = ls.iter().collect();
3303 let call = Call { eligible: 2, order: &order, tail: &[], pt: 0.0, budget: 16_000, model: "", prompt_hash: String::new(), dur_ms: 1, usd: 0.0 };
3304 let s = summary::Summary::default();
3305 let basis = Basis { summary: &s, had: &[], why: "" };
3306 let routed = |source: &'static str, xk: Option<u32>, xs: Option<u32>| Routed {
3307 ask: Vec::new(),
3308 free: vec![&ls[0], &ls[1]],
3309 placebo: Vec::new(),
3310 p: vec![0.0, 0.0],
3311 pf: vec![1.0, 1.0],
3312 log: Log { policy: "learned", source, xk, xs, ..Log::default() },
3313 };
3314 let record = |r: Option<&Routed>| graded_outcome("s", "r", "0123456789ab", &applied, r.map(|r| (r, &basis)), &s, &call);
3315 let drawn = record(Some(&routed(policy::EXPERIMENT_CONTROL, Some(2), Some(0))));
3316 assert_eq!((drawn.source.as_str(), drawn.xk, drawn.xs), (policy::EXPERIMENT_CONTROL, Some(2), Some(0)));
3317 let outside = record(Some(&routed(policy::EVIDENCE, None, None)));
3318 assert_eq!((outside.source.as_str(), outside.xk, outside.xs), (policy::EVIDENCE, None, None));
3319 let failed = record(None);
3320 assert_eq!((failed.failed, failed.xk, failed.xs), (true, None, None));
3321 assert_eq!((drawn.budget, failed.budget), (16_000, 16_000));
3323 }
3324
3325 fn graded_records(lessons: &std::path::Path) -> Vec<outcome::Outcome> {
3326 outcome::read(&outcome::path_beside(lessons)).into_iter().filter(|o| o.kind == outcome::GRADED).collect()
3327 }
3328
3329 #[test]
3332 fn a_check_decides_on_the_summary_under_the_lock_with_every_decision_before_it() {
3333 use crate::select::{Policy, policy, summary};
3334 let lessons = decide_store("fresh");
3335 let stale = summary::refresh(&lessons);
3337 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3338 let ls = [c("A"), c("B"), c("C"), c("D")];
3339 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3340 let mut gates = Vec::new();
3341 for check in ["0000000000a1", "0000000000b2"] {
3344 let at = Decide { session: "decide-fresh", check, channel: "live", lessons: &lessons, tokens: 0, recorded: true };
3345 let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |r, b| record_beside(&lessons, &applied, check, r, b));
3346 gates.push(r.log.gate);
3347 }
3348 let g = graded_records(&lessons);
3349 assert_eq!(g.len(), 2);
3350 assert!(g.iter().all(|o| o.policy == "learned" && o.reason.is_empty()), "{g:?}");
3351 assert_ne!(g[0].free, Vec::<String>::new(), "the first deviates: everything free at a live check, {:?}", g[0]);
3352 assert_eq!(g[0].seen, stale.key(), "nothing was decided before the first");
3353 let log = std::fs::read_to_string(outcome::path_beside(&lessons)).unwrap();
3355 let first = log.lines().next().unwrap();
3356 let after = summary::fold(&format!("{first}\n"), stale.clone());
3357 assert_eq!(after.gate.checks, stale.gate.checks + 1, "the first's decision is in the gate's ledger");
3358 assert_eq!(g[1].seen, after.key(), "decided on the summary loaded under the lock, not the one read at its start");
3359 assert_eq!(gates[1], policy::remaining(&after.gate).max(0.0), "against the gate with the first's entry");
3360 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3361 }
3362
3363 #[test]
3369 fn two_checks_deciding_at_once_never_both_take_the_gate() {
3370 use crate::select::{Policy, policy, summary};
3371 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3372 let ls = [c("A"), c("B"), c("C"), c("D")];
3373 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3374 let mut stores = Vec::new();
3375 for (name, sure, sources) in [("race", true, &[policy::EVIDENCE][..]), ("race-test", false, &[policy::EXPERIMENT, policy::EXPERIMENT_CONTROL][..])] {
3376 let lessons = decide_store_with(name, sure);
3377 let stale = summary::refresh(&lessons);
3378 let both = std::sync::Barrier::new(2);
3381 std::thread::scope(|s| {
3382 for check in ["0000000000c1", "0000000000c2"] {
3383 let (applied, lessons, stale, both) = (&applied, &lessons, &stale, &both);
3384 s.spawn(move || {
3385 let at = Decide { session: "decide-race", check, channel: "live", lessons, tokens: 0, recorded: true };
3386 decide(Policy::Learned, applied, &at, stale, std::time::Duration::ZERO, |r, b| {
3387 both.wait();
3388 record_beside(lessons, applied, check, r, b)
3389 });
3390 });
3391 }
3392 });
3393 let mut got: Vec<(String, String, String, bool)> =
3394 graded_records(&lessons).into_iter().map(|o| (o.policy, o.source, o.reason, o.xk.is_some())).collect();
3395 got.sort();
3396 assert_eq!(got.len(), 2, "{name}: {got:?}");
3397 assert_eq!(got[0], ("fixed".to_string(), "fixed".to_string(), outcome::DECIDED_BUSY.to_string(), false), "{name}: the other took the fixed rule");
3398 let (policy, source, reason, drawn) = &got[1];
3399 assert!(policy == "learned" && sources.contains(&source.as_str()) && reason.is_empty(), "{name}: one decided under the lock: {got:?}");
3400 assert_eq!(*drawn, !sure, "{name}: the test's draw on its record alone");
3401 assert!(policy::remaining(&summary::refresh(&lessons).gate) >= -1e-9, "{name}: the gate never overspent");
3402 stores.push(lessons);
3403 }
3404 let stale = summary::refresh(&stores[0]);
3406 let gone = std::path::Path::new("/nonexistent/borg-decide/lessons.jsonl");
3407 let at = Decide { session: "decide-race", check: "0000000000c3", channel: "live", lessons: gone, tokens: 0, recorded: true };
3408 let mut why = "";
3409 let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |_, b| {
3410 why = b.why;
3411 true
3412 });
3413 assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_NO_LOCK));
3414 for lessons in stores {
3415 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3416 }
3417 }
3418
3419 #[test]
3423 fn a_policy_that_reads_no_gate_decides_by_itself_whoever_holds_the_lock() {
3424 use crate::select::{Ctx, Policy, graded_under, summary};
3425 let lessons = decide_store("nolock");
3426 let stale = summary::refresh(&lessons);
3427 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3428 let ls = [c("A"), c("B"), c("C"), c("D")];
3429 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 3, 2]).collect();
3430 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3431 let held = lock_within(&decide_lock(&lessons), std::time::Duration::ZERO).unwrap();
3432 let check = "0000000000e1";
3433 let at = Decide { session: "decide-nolock", check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
3434 let ctx = Ctx {
3435 session: at.session,
3436 check,
3437 channel: at.channel,
3438 lessons: &lessons,
3439 summary: &stale,
3440 tokens: 0,
3441 had: &[],
3442 alpha: crate::select::alpha(&lessons),
3443 };
3444 let fixed = ids(&graded_under(Policy::Fixed, &applied, &ctx).ask);
3445 for policy in [Policy::Fixed, Policy::Cap(1), Policy::Attention] {
3446 let own = graded_under(policy, &applied, &ctx);
3447 let mut got = None;
3448 let r = decide(policy, &applied, &at, &stale, std::time::Duration::ZERO, |r, b| {
3449 got = Some((r.log.policy, b.why, b.summary.key()));
3450 true
3451 });
3452 assert_eq!(got, Some((policy.name(), "", stale.key())), "{policy:?}");
3453 assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&own.ask), ids(&own.free)), "{policy:?}: its own rule");
3454 }
3455 assert_ne!(ids(&graded_under(Policy::Cap(1), &applied, &ctx).ask), fixed);
3457 let mut why = "";
3458 let r = decide(Policy::Learned, &applied, &at, &stale, std::time::Duration::ZERO, |_, b| {
3459 why = b.why;
3460 true
3461 });
3462 assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_BUSY), "the learned policy alone waits for the lock");
3463 drop(held);
3464 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3465 }
3466
3467 #[test]
3472 fn a_learned_decision_whose_record_cannot_land_takes_the_fixed_rule() {
3473 use crate::select::{Ctx, Policy, graded_under, policy, summary};
3474 let lessons = decide_store("unrecorded");
3475 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3476 let ls = [c("A"), c("B"), c("C"), c("D")];
3477 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3478 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3479 let at = |check: &'static str, recorded: bool| Decide { session: "decide-unrecorded", check, channel: "live", lessons: &lessons, tokens: 0, recorded };
3480 let fixed = |check: &str, s: &summary::Summary| {
3481 let ctx = Ctx {
3482 session: "decide-unrecorded",
3483 check,
3484 channel: "live",
3485 lessons: &lessons,
3486 summary: s,
3487 tokens: 0,
3488 had: &[],
3489 alpha: crate::select::alpha(&lessons),
3490 };
3491 let r = graded_under(Policy::Fixed, &applied, &ctx);
3492 (ids(&r.ask), ids(&r.free), ids(&r.placebo))
3493 };
3494 let stale = summary::refresh(&lessons);
3496 let r = decide(Policy::Learned, &applied, &at("0000000000f1", true), &stale, std::time::Duration::ZERO, |r, b| {
3497 record_beside(&lessons, &applied, "0000000000f1", r, b)
3498 });
3499 assert_eq!(r.log.source, policy::EVIDENCE, "{:?}", r.log);
3500 let (ask, free, _) = fixed("0000000000f1", &stale);
3501 assert_ne!((ids(&r.ask), ids(&r.free)), (ask, free), "it deviated");
3502 let gate = summary::refresh(&lessons).gate;
3503 for check in ["0000000000f2", "0000000000f3"] {
3505 let stale = summary::refresh(&lessons);
3506 let mut tried = Vec::new();
3507 let r = decide(Policy::Learned, &applied, &at(check, false), &stale, std::time::Duration::ZERO, |r, b| {
3508 tried.push((r.log.policy, b.why));
3509 false
3510 });
3511 assert_eq!(tried, [("fixed", outcome::DECIDED_NO_RECORD)], "{check}");
3512 assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed(check, &stale), "{check}");
3513 }
3514 let stale = summary::refresh(&lessons);
3517 let mut tried = Vec::new();
3518 let r = decide(Policy::Learned, &applied, &at("0000000000f4", true), &stale, DECIDE_WAIT, |r, b| {
3520 tried.push((r.log.policy, b.why));
3521 false
3522 });
3523 assert_eq!(tried, [("learned", ""), ("fixed", outcome::DECIDED_NO_RECORD)]);
3524 assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed("0000000000f4", &stale));
3525 assert_eq!(graded_records(&lessons).len(), 1, "only the first check's record");
3526 assert_eq!(summary::refresh(&lessons).gate, gate, "no unrecorded decision moved the gate");
3527 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3528 }
3529
3530 #[test]
3535 fn a_lesson_waiting_in_the_queue_never_takes_a_free_or_placebo_slot() {
3536 use crate::select::{Policy, summary};
3537 let lessons = decide_store("queued");
3538 let stale = summary::refresh(&lessons);
3539 let session = format!("decide-queued-{}", std::process::id());
3540 let _ = std::fs::remove_file(state_file(&session));
3541 std::fs::write(queued_file(&session), "X @0000000000d0\nlive Y @0000000000d0\nplacebo Z @0000000000d0\n").unwrap();
3542 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3543 let named: Vec<String> = (1..=10).map(|i| format!("L{i:02}")).collect();
3544 let ls: Vec<conscience::Challenge> = ["X", "Y", "Z"].iter().map(|i| c(i)).chain(named.iter().map(|i| c(i))).collect();
3545 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3546 let first: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if ["X", "Y"].contains(&c.id.as_str()) { 2 } else { 1 })).collect();
3549 let last: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if c.id.starts_with('L') { 2 } else { 1 })).collect();
3550 let waits = |l: &[String]| l.iter().any(|i| i == "X" || i == "Y");
3551 for (check, applied) in [("0000000000d1", &first), ("0000000000d2", &last)] {
3552 let at = Decide { session: &session, check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
3553 let r = decide(Policy::Fixed, applied, &at, &stale, std::time::Duration::ZERO, |r, b| record_beside(&lessons, applied, check, r, b));
3554 let (free, placebo) = (ids(&r.free), ids(&r.placebo));
3555 assert_eq!(free.len(), conscience::CAP, "{check}: every free slot taken: {free:?}");
3556 assert!(!waits(&free) && !waits(&placebo), "{check}: free {free:?}, placebo {placebo:?}");
3557 }
3558 assert_eq!(graded_records(&lessons)[1].placebo, ["Z"], "waiting as a placebo, Z is still drawn");
3559 assert!(graded_records(&lessons)[0].free.contains(&"Z".to_string()), "and still goes free");
3560 let g = graded_records(&lessons);
3561 assert_eq!(g.iter().map(|o| o.nofree.clone()).collect::<Vec<_>>(), [["X", "Y"], ["X", "Y"]], "{g:?}");
3562 let _ = std::fs::remove_file(queued_file(&session));
3563 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3564 }
3565
3566 #[test]
3567 fn only_an_adapter_reads_which_messages_an_answer_showed() {
3568 let out = json!({"hookSpecificOutput": {"additionalContext": "[borg] Message from s1: hi"}, "shown_ids": ["m0123456789ab"]});
3569 for name in ["claude", "codex"] {
3570 let h = harness::by_name(name).unwrap();
3571 assert_eq!(for_harness(h, out.clone()), json!({"hookSpecificOutput": out["hookSpecificOutput"]}), "{name}");
3572 }
3573 for name in ["omp", "opencode"] {
3574 assert_eq!(for_harness(harness::by_name(name).unwrap(), out.clone()), out, "{name}");
3575 }
3576 }
3577}