1use std::io::{Read, Write};
11use std::path::{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 state_file_in(&dir, session)
24}
25
26pub(crate) fn state_file_in(dir: &Path, session: &str) -> PathBuf {
28 let safe: String = session.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
29 dir.join(format!("{safe}.ctx"))
30}
31
32pub(crate) fn append(session: &str, text: &str) {
35 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session)) {
36 let _ = f.write_all(format!("{text}\n").as_bytes());
37 }
38}
39
40pub(crate) fn touched(input: &Value, tool: &str) -> String {
42 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
43 match tool {
44 "Bash" => {
45 let cmd = s("command");
46 let paths = bash_read_paths(&cmd);
47 if paths.is_empty() { cmd.chars().take(COMMAND_CHARS).collect() } else { paths.join(" ") }
48 }
49 "Grep" => format!("{} {}", s("pattern"), s("path")),
50 "Glob" => s("pattern"),
51 _ => {
52 let mut t = [s("file_path"), s("notebook_path"), s("path")].join(" ").trim().to_string();
53 for p in further_files(input) {
55 if !t.is_empty() {
56 t.push(' ');
57 }
58 t.push_str(p);
59 }
60 t
61 }
62 }
63}
64
65const COMMAND_CHARS: usize = borg_trace::dialogue::TOUCH_CHARS;
69
70pub(crate) fn further_files(input: &Value) -> impl Iterator<Item = &str> {
74 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())
75}
76
77pub(crate) fn repo_of(cwd: &str) -> String {
78 borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
79}
80
81fn stat(session: &str, input: &Value, kind: &str, fill: impl FnOnce(&mut crate::stats::Event)) {
83 let home = learn::home();
84 let mut ev = crate::stats::Event::new(kind, learn::now_ms());
85 ev.session = session.into();
86 ev.repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
87 ev.engineer = crate::presence::read(&home, session).map(|p| p.engineer).unwrap_or_default();
89 fill(&mut ev);
90 crate::stats::record(&home, &ev);
91}
92
93fn prompts_file(session: &str) -> PathBuf {
95 state_file(session).with_extension("prompts")
96}
97
98fn where_file(session: &str) -> PathBuf {
102 state_file(session).with_extension("where")
103}
104
105fn where_now(session: &str) -> String {
108 let paths = std::fs::read_to_string(where_file(session)).unwrap_or_default();
109 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
110 format!("{paths}\n{latest}")
111}
112
113fn note_where(session: &str, text: &str) {
114 if text.trim().is_empty() {
115 return;
116 }
117 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(where_file(session)) {
118 let _ = writeln!(f, "{text}");
119 }
120}
121
122fn task_file(session: &str) -> PathBuf {
123 state_file(session).with_extension("task")
124}
125
126fn denied_file(session: &str) -> PathBuf {
130 state_file(session).with_extension("denied")
131}
132
133fn capture_prompt(h: &dyn Harness, session: &str, input: &Value, prompt: &str) {
136 if !learning() {
137 return;
138 }
139 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
140 if let Ok(tool) = std::fs::read_to_string(denied_file(session)) {
141 let _ = std::fs::remove_file(denied_file(session));
142 let said = prompt.trim();
143 if said.len() > 3 && !said.starts_with('<') {
144 record(session, input, task, proposal(h, input), Some(tool), said.chars().take(learn::EXCERPT_CHARS).collect());
145 return;
146 }
147 }
148 if !learn::is_correction(prompt) || task.is_empty() {
149 let task: String = prompt.chars().take(learn::TASK_CHARS).collect();
150 let _ = std::fs::write(task_file(session), task);
151 return;
152 }
153 record(session, input, task, proposal(h, input), None, prompt.chars().take(learn::EXCERPT_CHARS).collect());
154}
155
156fn veto(h: &dyn Harness, session: &str, input: &Value, reason: &str) {
159 let _ = std::fs::remove_file(denied_file(session));
160 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
161 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
162 let said = crate::veto::Said {
163 session,
164 repo: &repo_of(cwd),
165 engineer: &learn::engineer(cwd),
166 task: &task,
167 agent: &proposal(h, input),
168 reason,
169 ms: learn::now_ms(),
170 via: "prompt",
171 };
172 if let Ok(v) = crate::veto::record(&learn::home(), &said) {
173 append(session, &format!("#borg-episode {}", v.episode));
174 }
175}
176
177fn capture_rejection(h: &dyn Harness, session: &str, input: &Value) {
180 if !learning() {
181 return;
182 }
183 let error = input.get("error").and_then(Value::as_str).unwrap_or_default();
184 let tool = format!(
185 "{} {}",
186 input.get("tool_name").and_then(Value::as_str).unwrap_or_default(),
187 input.get("tool_input").map(|v| v.to_string()).unwrap_or_default().chars().take(learn::EXCERPT_CHARS).collect::<String>()
188 );
189 let Some(said) = learn::rejection(error) else {
190 if learn::denied(error) {
191 let _ = std::fs::write(denied_file(session), tool);
192 }
193 return;
194 };
195 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
196 record(session, input, task, proposal(h, input), Some(tool), said);
197}
198
199fn proposal(h: &dyn Harness, input: &Value) -> String {
202 let t = full_proposal(h, input);
203 t.chars().rev().take(learn::PROPOSAL_CHARS).collect::<Vec<_>>().into_iter().rev().collect()
204}
205
206fn full_proposal(h: &dyn Harness, input: &Value) -> String {
209 harness::last_message(h.caps(), input)
210}
211
212fn record(session: &str, input: &Value, task: String, agent: String, rejected_tool: Option<String>, correction: String) {
213 let ms = learn::now_ms();
214 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
215 let e = learn::Episode {
216 id: format!("{}-{ms}", session.chars().take(8).collect::<String>()),
217 ms,
218 repo: repo_of(cwd),
219 session: session.into(),
220 task,
221 agent,
222 rejected_tool,
223 correction,
224 engineer: learn::engineer(cwd),
225 ..learn::Episode::default()
226 };
227 let _ = learn::record_episode(&e);
228 append(session, &format!("#borg-episode {}", e.id));
229}
230
231fn lessons(session: &str) -> Vec<conscience::Challenge> {
236 loaded(session).0
237}
238
239fn judged(all: &[conscience::Challenge], withheld: Vec<conscience::Challenge>) -> std::borrow::Cow<'_, [conscience::Challenge]> {
243 if withheld.is_empty() { all.into() } else { all.iter().cloned().chain(withheld).collect::<Vec<_>>().into() }
244}
245
246fn loaded(session: &str) -> (Vec<conscience::Challenge>, Vec<conscience::Challenge>) {
248 let before = std::env::var("BORG_BEFORE").ok().and_then(|v| v.parse().ok());
249 let mut all = conscience::load_store_for(&lessons_path(), before, Some(session));
251 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
254 let repos = crate::systems::work_repos(&where_text);
255 if crate::systems::on() {
256 crate::systems::gate(&mut all, &repos);
257 }
258 all.retain(|c| crate::reach::deliverable(c, &repos));
260 let withheld = crate::absorb::withhold_beside(&lessons_path(), &mut all, before);
262 if crate::concepts::on() {
265 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
266 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
267 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
268 crate::concepts::apply(&mut all, &crate::concepts::of_work(&edits, &where_text, &latest), crate::concepts::beta());
269 }
270 (all, withheld)
271}
272
273fn lessons_path() -> PathBuf {
275 std::env::var_os("BORG_CONSCIENCE").map(PathBuf::from).unwrap_or_else(|| learn::home().join("lessons.jsonl"))
276}
277
278fn edits_file(session: &str) -> PathBuf {
283 state_file(session).with_extension("edits")
284}
285
286fn cost_file(session: &str) -> PathBuf {
290 state_file(session).with_extension("cost")
291}
292
293fn changes_file(session: &str) -> PathBuf {
296 state_file(session).with_extension("changes")
297}
298
299fn log_cost(session: &str, line: &str) {
300 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(cost_file(session)) {
301 let _ = writeln!(f, "{line}");
302 }
303}
304
305fn early_acted(session: &str, all: &[conscience::Challenge], final_msg: &str, upto: Option<usize>) -> std::collections::HashSet<String> {
312 let edits = edits_upto(session, upto);
313 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
314 acted_since(&state, &edits, all, final_msg)
315}
316
317fn acted_since(state: &str, edits: &str, all: &[conscience::Challenge], final_msg: &str) -> std::collections::HashSet<String> {
329 use std::collections::{HashMap, HashSet};
330 let lines: Vec<&str> = edits.lines().filter(|l| !l.starts_with(WORK)).collect();
331 let deliveries: Vec<(usize, &str, &str)> = lines
332 .iter()
333 .enumerate()
334 .filter_map(|(p, l)| l.strip_prefix("#borg-early ").or_else(|| l.strip_prefix("#borg-queued ")).map(|list| (p, *l, list)))
335 .collect();
336 let mut out = HashSet::new();
337 if deliveries.is_empty() {
338 return out;
339 }
340 let words = crate::memory::Distinctive::new(all);
341 let mut slot: HashMap<String, usize> = HashMap::new();
343 let mut asked_words: HashMap<usize, Vec<usize>> = HashMap::new();
344 let mut asked: Vec<Vec<usize>> = Vec::with_capacity(deliveries.len());
345 for (_, _, list) in &deliveries {
346 let ids: HashSet<&str> = ids_in(list).collect();
347 let these: Vec<usize> = all.iter().enumerate().filter(|(_, c)| ids.contains(c.id.as_str())).map(|(k, _)| k).collect();
348 for k in &these {
349 asked_words.entry(*k).or_insert_with(|| {
350 words
351 .words(&all[*k].question)
352 .into_iter()
353 .map(|w| {
354 let n = slot.len();
355 *slot.entry(w).or_insert(n)
356 })
357 .collect()
358 });
359 }
360 asked.push(these);
361 }
362 let n = slot.len();
363 let (mut first_byte, mut first_line, mut last_work, mut in_final) = (vec![usize::MAX; n], vec![usize::MAX; n], vec![None; n], vec![false; n]);
366 conscience::tokens_at(state, |at, t| {
367 if let Some(s) = slot.get(t) {
368 first_byte[*s] = first_byte[*s].min(at);
369 }
370 });
371 for (q, l) in lines.iter().enumerate() {
372 let work = !l.starts_with("#borg-");
373 conscience::tokens_at(l, |_, t| {
374 if let Some(s) = slot.get(t) {
375 first_line[*s] = first_line[*s].min(q);
376 if work {
377 last_work[*s] = Some(q);
378 }
379 }
380 });
381 }
382 conscience::tokens_at(final_msg, |_, t| {
383 if let Some(s) = slot.get(t) {
384 in_final[*s] = true;
385 }
386 });
387 let mut cut: HashMap<&str, usize> = HashMap::new();
391 for marker in ["#borg-early ", "#borg-queued "] {
392 for (at, _) in state.match_indices(marker) {
393 if let Some(end) = state[at..].find('\n') {
394 cut.entry(&state[at..at + end]).or_insert(at);
395 }
396 }
397 }
398 let lower = final_msg.to_lowercase();
399 let mut declared: HashMap<usize, Option<bool>> = HashMap::new();
400 for ((p, l, _), these) in deliveries.iter().zip(&asked) {
401 let i = cut.get(l).copied().unwrap_or(state.len());
402 let new = |s: &&usize| (last_work[**s].is_some_and(|q| q > *p) || in_final[**s]) && first_byte[**s] >= i && first_line[**s] >= *p;
403 for k in these {
404 let c = &all[*k];
405 let applies = match *declared.entry(*k).or_insert_with(|| crate::memory::declared(&lower, &c.id.to_lowercase())) {
406 Some(v) => v,
407 None => asked_words[k].iter().filter(new).count() >= crate::memory::NOVEL_WORDS,
408 };
409 if applies {
410 out.insert(c.id.clone());
411 }
412 }
413 }
414 out
415}
416
417fn edits_upto(session: &str, upto: Option<usize>) -> String {
420 let mut edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
421 if let Some(n) = upto.filter(|n| edits.is_char_boundary(*n)) {
422 edits.truncate(n);
423 }
424 edits
425}
426
427fn done_in<'a>(state: &'a str, acted: &std::collections::HashSet<String>) -> std::collections::HashSet<&'a str> {
432 let mut done = listed(state, "#borg-asked ");
433 done.extend(listed(state, "#borg-early ").into_iter().chain(listed(state, "#borg-queued ")).filter(|i| acted.contains(*i)));
434 done
435}
436
437fn applied_in(state: &str) -> impl Iterator<Item = &str> {
440 state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")).flat_map(|l| l.split_once(' ').map_or("", |(_, items)| items).split(',')).filter_map(
441 |item| {
442 let id = item.rsplitn(3, ':').nth(2).unwrap_or_default();
443 (!id.is_empty()).then_some(id)
444 },
445 )
446}
447
448fn had_in(state: &str) -> std::collections::HashSet<&str> {
451 let mut had = listed(state, "#borg-asked ");
452 had.extend(listed(state, "#borg-early "));
453 had.extend(listed(state, "#borg-queued "));
454 had
455}
456
457const WORK: &str = "#borg-work ";
462
463pub(crate) fn edit_text(tool: &str, input: &Value) -> Option<String> {
466 if !["edit", "write", "multiedit", "notebookedit"].contains(&tool.to_ascii_lowercase().as_str()) {
467 return None;
468 }
469 let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
470 let mut text = [s(input, "file_path"), s(input, "notebook_path")].join(" ");
471 for p in further_files(input) {
472 text.push(' ');
473 text.push_str(p);
474 }
475 for k in ["new_string", "content", "new_source"] {
476 text.push(' ');
477 text.push_str(&s(input, k));
478 }
479 for e in input.get("edits").and_then(Value::as_array).into_iter().flatten() {
480 text.push(' ');
481 text.push_str(&s(e, "new_string"));
482 }
483 Some(text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(EDIT_CHARS).collect())
484}
485
486const EDIT_CHARS: usize = 600;
489
490const EVIDENCE_CHARS: usize = 4000;
496const _: () = assert!(EVIDENCE_CHARS >= 3 * predict::JUDGE_PART);
497
498pub(crate) fn one_line(text: &str, n: usize) -> String {
500 text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(n).collect()
501}
502
503fn note_answer(h: &dyn Harness, session: &str, input: &Value) {
507 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
508 let lines: Vec<&str> = edits.lines().collect();
509 let Some(i) = lines.iter().rposition(|l| l.starts_with("#borg-asked ")) else { return };
510 if lines[i + 1..].iter().any(|l| l.starts_with("#borg-answer ")) {
511 return;
512 }
513 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
514 let _ = writeln!(f, "#borg-answer {}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
515 }
516}
517
518const MAX_PENDING: usize = conscience::CAP;
524const MAX_JUDGE_TRIES: usize = 2;
530
531pub(crate) const END_START_BUDGET: usize = 72_000;
539
540const END_LESSONS_SHARE: usize = 2;
544
545#[derive(Debug, Clone, Copy, PartialEq)]
547pub(crate) struct EndBudget {
548 pub chars: usize,
550 pub draw: Option<(f64, bool)>,
553}
554
555pub(crate) fn end_budget(state: &std::path::Path, model: &str, seed: &str) -> EndBudget {
561 if let Some(pinned) = std::env::var("BORG_END_BUDGET").ok().and_then(|v| v.trim().parse::<usize>().ok()).filter(|n| *n > 0) {
562 return EndBudget { chars: pinned, draw: None };
563 }
564 let len = crate::judge_length::length(state, model);
565 let mut h = blake3::Hasher::new();
566 h.update(b"borg-end-explore-v1\0");
567 h.update(seed.as_bytes());
568 let bytes: [u8; 8] = h.finalize().as_bytes()[..8].try_into().unwrap_or_default();
569 let u = crate::memory::Rng::new(u64::from_le_bytes(bytes)).unit();
570 match len.explore {
571 Some(up) if u < len.psi => EndBudget { chars: up, draw: Some((len.psi, true)) },
572 _ => EndBudget { chars: len.chars, draw: Some((len.psi, false)) },
573 }
574}
575
576pub(crate) fn judge_model(spec: &str) -> String {
579 crate::runner::choose(Some(spec)).first().map(crate::runner::Choice::label).unwrap_or_default()
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590enum Cut {
591 Newest,
592 After,
593 Ends,
594}
595
596impl Cut {
597 fn from_env() -> Cut {
599 match std::env::var("BORG_END_WORK").unwrap_or_default().trim().to_ascii_lowercase().as_str() {
600 "newest" => Cut::Newest,
601 "after" => Cut::After,
602 _ => Cut::Ends,
603 }
604 }
605
606 fn word(self) -> &'static str {
608 match self {
609 Cut::Newest => "newest",
610 Cut::After => "after",
611 Cut::Ends => "ends",
612 }
613 }
614
615 fn window(self, text: &[char], n: usize, at: Option<usize>) -> String {
619 let all = text.len();
620 let s = |r: std::ops::Range<usize>| text[r].iter().collect::<String>();
621 let mark = |cut: bool| if cut { "…" } else { "" };
622 let at = at.map(|a| a.min(all));
623 match (self, at) {
624 (Cut::After, Some(a)) => {
625 let start = a.min(all - n);
626 format!("{}{}{}", mark(start > 0), s(start..start + n), mark(start + n < all))
627 }
628 (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)),
629 (Cut::Ends, Some(_)) => Cut::After.window(text, n, at),
630 _ => format!("…{}", s(all - n..all)),
631 }
632 }
633}
634
635fn end_timeout() -> std::time::Duration {
641 learn::learn_timeout().min(predict::timeout().saturating_mul(2))
642}
643
644const END_JUDGED: &str = "#borg-end-judged ";
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653enum When {
654 Stop,
655 End,
656}
657
658const DELIVERED: [&str; 4] = ["#borg-asked ", "#borg-early ", "#borg-queued ", "#borg-placebo "];
663
664const ROUTED: &str = "#borg-routed ";
668
669fn is_marker(line: &str) -> bool {
672 DELIVERED.iter().any(|m| line.starts_with(m)) || line.starts_with("#borg-live ") || line.starts_with(ROUTED)
673}
674
675#[must_use]
680pub fn stripped(check: &str, lesson: &str) -> bool {
681 let mut h = blake3::Hasher::new();
682 h.update(b"borg-strip-v1\0");
683 h.update(format!("{check}\0{lesson}").as_bytes());
684 h.finalize().as_bytes()[0] & 1 == 1
685}
686
687fn verdicts_in(state: &str) -> (std::collections::HashMap<&str, usize>, std::collections::HashSet<&str>) {
691 let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
692 let mut settled = std::collections::HashSet::new();
693 for l in state.lines() {
694 if let Some(l) = l.strip_prefix("#borg-judged ") {
695 let (id, label) = l.split_once('=').unwrap_or((l, ""));
696 *tries.entry(id).or_default() += 1;
697 if label.trim() != "pending" {
698 settled.insert(id);
699 }
700 } else if let Some(list) = l.strip_prefix(END_JUDGED) {
701 settled.extend(ids_in(list));
702 } else if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
703 for id in ids_in(list) {
704 tries.remove(id);
705 settled.remove(id);
706 }
707 }
708 }
709 (tries, settled)
710}
711
712fn open_in(state: &str) -> std::collections::HashSet<&str> {
715 let (_, settled) = verdicts_in(state);
716 let mut open: std::collections::HashSet<&str> = DELIVERED.iter().flat_map(|m| listed(state, m)).collect();
717 open.retain(|id| !settled.contains(id));
718 open
719}
720
721struct Judging<'a> {
724 pending: Vec<predict::Pending<'a>>,
725 question: bool,
726 crowded: Vec<String>,
727}
728
729fn pending<'a>(session: &str, state: &str, all: &'a [conscience::Challenge], final_msg: &str, when: When, judging: &str) -> Judging<'a> {
747 pending_upto(session, state, all, final_msg, when, judging, None)
748}
749
750fn pending_upto<'a>(
752 session: &str,
753 state: &str,
754 all: &'a [conscience::Challenge],
755 final_msg: &str,
756 when: When,
757 judging: &str,
758 edits: Option<usize>,
759) -> Judging<'a> {
760 let (tries, settled) = verdicts_in(state);
761 let edits = edits_upto(session, edits);
762 let lines: Vec<&str> = edits.lines().collect();
763 let part = predict::JUDGE_PART;
764 struct Delivery<'l> {
766 channel: &'static str,
767 question: bool,
769 before: &'l str,
770 answers: Vec<&'l str>,
771 work: Vec<&'l str>,
772 fresh: Vec<(&'l str, &'l str, bool)>,
773 }
774 let mut deliveries: Vec<Delivery> = Vec::new();
775 for (p, l) in lines.iter().enumerate() {
776 let Some((marker, list)) = DELIVERED.iter().find_map(|m| l.strip_prefix(m).map(|rest| (*m, rest))) else { continue };
777 let question = marker == "#borg-asked ";
778 let later = &lines[p + 1..];
779 let channel = match marker {
780 "#borg-asked " if later.first().is_some_and(|n| n.starts_with("#borg-live ")) => "live",
781 "#borg-asked " => "stop",
782 "#borg-early " => "early",
783 "#borg-queued " => "queued",
784 _ => outcome::PLACEBO,
785 };
786 let (list, tag) = tagged(list);
787 let before = match lines[..p].iter().rev().find(|b| !is_marker(b)) {
790 Some(b) if b.starts_with(WORK) => &b[WORK.len()..],
791 Some(b) if !b.starts_with("#borg-") => b,
792 _ => "",
793 };
794 let routed: std::collections::HashMap<&str, &str> =
795 later.first().and_then(|n| n.strip_prefix(ROUTED)).into_iter().flat_map(|r| r.split(',')).filter_map(|it| it.split_once('=')).collect();
796 let fresh: Vec<&str> = list
797 .split(',')
798 .map(str::trim)
799 .filter(|i| !i.is_empty() && !settled.contains(i) && (when == When::End || tries.get(i).copied().unwrap_or(0) < MAX_JUDGE_TRIES))
800 .filter(|i| all.iter().any(|c| c.id == *i))
801 .collect();
802 if fresh.is_empty() {
805 continue;
806 }
807 let check_of = |id: &str| routed.get(id).copied().or(tag).unwrap_or_default();
808 let fresh =
809 fresh.into_iter().map(|id| (id, check_of(id), question && stripped(if check_of(id).is_empty() { judging } else { check_of(id) }, id))).collect();
810 let answers: Vec<&str> = if question { later.iter().filter_map(|l| l.strip_prefix("#borg-answer ")).collect() } else { Vec::new() };
811 let work: Vec<&str> = later.iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
812 deliveries.push(Delivery { channel, question, before, answers, work, fresh });
813 }
814 let strip: Vec<&str> = deliveries.iter().flat_map(|d| &d.fresh).filter(|f| f.2).map(|f| f.0).collect();
817 let blind = |text: &str| strip.iter().fold(text.to_string(), |t, id| crate::memory::strip_declaration(&t, id));
818 let turn = predict::clip(&one_line(&blind(final_msg), part + 1), part);
819 let mut out: Vec<(predict::Pending, bool)> = Vec::new();
821 for d in deliveries {
822 let answer = blind(&d.answers.join(" "));
824 let said = answer.to_lowercase();
825 let since: Vec<&str> = std::iter::once(answer.as_str()).filter(|a| !a.is_empty()).chain(d.work.iter().copied()).collect();
826 let after = format!("work since: {}\nthis turn: {turn}", predict::clip(&blind(&since.join(" | ")), 2 * part));
827 let before = blind(d.before);
828 for (id, check, stripped) in d.fresh {
829 let Some(c) = all.iter().find(|c| c.id == id) else { continue };
830 out.retain(|x| x.0.lesson.id != id);
832 let declared = if stripped { None } else { crate::memory::declared(&said, &id.to_lowercase()) };
833 let pending =
834 predict::Pending { lesson: c, before: before.clone(), after: after.clone(), declared, check: check.to_string(), channel: d.channel, stripped };
835 out.push((pending, d.question));
836 }
837 }
838 let slots = if when == When::End { usize::MAX } else { MAX_PENDING };
839 let questions = out.iter().filter(|x| x.1).count().min(slots);
840 let mut room = [slots - questions, questions];
841 let (mut kept, mut crowded): (Vec<(predict::Pending, bool)>, Vec<String>) = (Vec::new(), Vec::new());
842 for x in out.into_iter().rev() {
843 let r = &mut room[usize::from(x.1)];
844 if *r > 0 {
845 *r -= 1;
846 kept.push(x);
847 } else {
848 crowded.push(x.0.lesson.id.clone());
849 }
850 }
851 kept.reverse();
852 crowded.sort();
853 Judging { pending: kept.into_iter().map(|x| x.0).collect(), question: questions > 0, crowded }
854}
855
856fn without_declarations<'t>(text: &'t str, pending: &[predict::Pending]) -> std::borrow::Cow<'t, str> {
859 pending.iter().filter(|p| p.stripped).fold(std::borrow::Cow::Borrowed(text), |t, p| crate::memory::strip_declaration(&t, &p.lesson.id).into())
860}
861
862fn note_unjudged(session: &str, ids: &[String], why: &str) {
866 if !ids.is_empty() {
867 append(session, &format!("#borg-unjudged {} why={why}", ids.join(",")));
868 }
869}
870
871pub(crate) fn unjudged(state: &str) -> std::collections::HashMap<String, &'static str> {
876 let mut why: std::collections::HashMap<String, &'static str> = std::collections::HashMap::new();
877 let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
878 for l in state.lines() {
879 if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
880 for id in ids_in(list) {
881 tries.remove(id);
882 why.insert(id.to_string(), outcome::UNJUDGED_NO_CALL);
883 }
884 } else if let Some(l) = l.strip_prefix("#borg-judged ") {
885 let (id, label) = l.split_once('=').unwrap_or((l, ""));
886 if label.trim() == "pending" {
887 let n = tries.entry(id).or_default();
888 *n += 1;
889 if *n >= MAX_JUDGE_TRIES {
890 why.insert(id.to_string(), outcome::UNJUDGED_TRIES);
891 }
892 }
893 } else if let Some(list) = l.strip_prefix(END_JUDGED) {
894 for id in ids_in(list) {
896 tries.insert(id, MAX_JUDGE_TRIES);
897 why.insert(id.to_string(), outcome::UNJUDGED_TRIES);
898 }
899 } else if let Some(rest) = l.strip_prefix("#borg-unjudged ") {
900 let (list, reason) = rest.rsplit_once(" why=").unwrap_or((rest, ""));
901 let reason = match reason.trim() {
902 "slots" => outcome::UNJUDGED_SLOTS,
903 "failed" => outcome::UNJUDGED_FAILED,
904 _ => continue,
905 };
906 for id in list.split(',').map(str::trim).filter(|i| !i.is_empty()) {
907 if tries.get(id).copied().unwrap_or(0) < MAX_JUDGE_TRIES {
908 why.insert(id.to_string(), reason);
909 }
910 }
911 }
912 }
913 why
914}
915
916fn record_verdicts(session: &str, repo: &str, at: &str, pending: &[predict::Pending], pred: &predict::Prediction, logged: &Logged) {
931 type Key<'k> = (&'k str, &'k str, &'k str, bool);
934 let mut records: std::collections::BTreeMap<Key, Vec<(String, String)>> = std::collections::BTreeMap::new();
935 for p in pending {
936 let judged = pred.resolved.iter().find(|j| j.id == p.lesson.id);
937 let word = match judged {
940 Some(j) if j.label().is_some() && (j.verdict != predict::Verdict::NotArisen || at == outcome::AT_END) => j.verdict.word(),
941 _ => "pending",
942 };
943 append(session, &format!("#borg-judged {}={word}", p.lesson.id));
944 let Some(j) = judged else { continue };
945 records.entry((j.verdict.kind(), p.channel, p.check.as_str(), p.stripped)).or_default().push((p.lesson.id.clone(), j.reason.clone()));
946 }
947 let at_field = if at.is_empty() { String::new() } else { format!(" at={at}") };
948 let line = judge_line(logged.call, pending, &pred.resolved, logged.chars, &pred.returned);
949 log_cost(session, &format!("{line} model={}{at_field}{}", logged.model, logged.fields));
950 if learning() {
951 let (home, ms) = (learn::home(), learn::now_ms());
952 let mut counted: std::collections::BTreeMap<(&str, &str), Vec<String>> = std::collections::BTreeMap::new();
954 for ((kind, channel, check, stripped), judged) in records {
955 let (lessons, reasons): (Vec<String>, Vec<String>) = judged.into_iter().unzip();
956 counted.entry((kind, channel)).or_default().extend(lessons.iter().cloned());
957 let o = outcome::Outcome {
958 kind: kind.into(),
959 session: session.into(),
960 lessons,
961 ms,
962 repo: repo.into(),
963 channel: channel.into(),
964 check: check.into(),
965 judge: predict::JUDGE_VERSION,
966 stripped,
967 at: at.into(),
968 reasons: if reasons.iter().all(String::is_empty) { Vec::new() } else { reasons },
970 ..Default::default()
971 };
972 let _ = learn::append_jsonl(&home.join("outcomes.jsonl"), &o);
973 }
974 for ((kind, channel), lessons) in counted {
975 outcome::count_result(
976 &home,
977 &outcome::Outcome { kind: kind.into(), session: session.into(), lessons, ms, repo: repo.into(), channel: channel.into(), ..Default::default() },
978 );
979 }
980 }
981}
982
983struct Logged {
988 call: usize,
989 chars: usize,
990 model: String,
991 fields: String,
992}
993
994fn judge_line(call: usize, pending: &[predict::Pending], resolved: &[predict::Judged], chars: usize, returned: &[String]) -> String {
1002 let unmatched: Vec<&str> = returned.iter().map(String::as_str).filter(|id| !pending.iter().any(|p| p.lesson.id == *id)).collect();
1003 let mine: Vec<&predict::Judged> = resolved.iter().filter(|j| pending.iter().any(|p| p.lesson.id == j.id)).collect();
1004 let settled = mine.iter().filter(|j| j.label().is_some()).count();
1005 let verdicts: Vec<String> = predict::Verdict::ALL.iter().map(|v| format!("{}:{}", v.word(), mine.iter().filter(|j| j.verdict == *v).count())).collect();
1006 let reason_chars: usize = mine.iter().map(|j| j.reason.chars().count()).sum();
1007 format!(
1008 "judge call={call} pending={} resolved={settled} chars={chars} returned={} unmatched={} verdicts={} reason_chars={reason_chars}",
1009 pending.len(),
1010 returned.join(","),
1011 unmatched.join(","),
1012 verdicts.join(",")
1013 )
1014}
1015
1016pub fn judge_session(session: &str) -> usize {
1039 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1040 if !learning() {
1041 return 0;
1042 }
1043 let ctx = state_file(session);
1044 let opened = |state: &str| state.lines().any(|l| DELIVERED.iter().any(|m| l.starts_with(m)));
1045 if !opened(&std::fs::read_to_string(&ctx).unwrap_or_default()) {
1046 return 0;
1047 }
1048 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
1049 if crate::runner::choose(Some(&model)).is_empty() {
1050 return 0;
1051 }
1052 let wait = u64::try_from(live::stale_ms()).unwrap_or(u64::MAX);
1056 while live::wait_free(&ctx, wait) == live::Lock::Held {}
1057 let Some(guard) = live::acquire(&ctx, learn::now_ms()) else { return 0 };
1058 let guard = std::sync::Mutex::new(guard);
1059 let state = std::fs::read_to_string(&ctx).unwrap_or_default();
1060 let (all, withheld) = loaded(session);
1061 let judged = judged(&all, withheld);
1062 let last = crate::notes::proposal(&crate::notes::state_dir(), session);
1063 let check = crate::select::check_id(session, "end", learn::now_ms(), std::process::id());
1064 let judging = pending(session, &state, &judged, &last, When::End, &check);
1065 let pend = &judging.pending;
1066 if pend.is_empty() {
1067 return 0;
1068 }
1069 let label = judge_model(&model);
1070 let budget = end_budget(ctx.parent().unwrap_or(std::path::Path::new(".")), &label, &check);
1072 let cut = Cut::from_env();
1073 let sources = session_sources(session, &state, pend, budget.chars);
1074 let groups = batches(pend, budget.chars);
1075 let (next, failures, answers, lost) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicBool::new(false));
1076 let repo = where_repo(session);
1077 let n = groups.len();
1078 let draw = budget.draw.map_or(String::new(), |(psi, explored)| format!(" psi={psi:.4} explore={}", u8::from(explored)));
1079 let mut call = state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count();
1082 let mut done = 0;
1083 let mut asked = vec![false; n];
1084 let (tx, rx) = std::sync::mpsc::channel::<(usize, Batch)>();
1085 std::thread::scope(|s| {
1086 for _ in 0..crate::runner::JOBS.min(n) {
1087 let tx = tx.clone();
1088 let (groups, next, failures, answers, lost, guard, sources, model) = (&groups, &next, &failures, &answers, &lost, &guard, &sources, &model);
1089 s.spawn(move || {
1090 while !lost.load(Ordering::SeqCst) && (answers.load(Ordering::SeqCst) > 0 || failures.load(Ordering::SeqCst) < crate::runner::MAX_FAILURES) {
1091 let i = next.fetch_add(1, Ordering::SeqCst);
1092 let Some(range) = groups.get(i) else { break };
1093 if guard.lock().map_or(true, |mut g| g.renew(learn::now_ms()) == live::Renewal::Lost) {
1095 lost.store(true, Ordering::SeqCst);
1096 break;
1097 }
1098 let b = ask_batch(&pend[range.clone()], sources, budget.chars, cut, model);
1099 (if b.pred.is_some() { answers } else { failures }).fetch_add(1, Ordering::SeqCst);
1100 if tx.send((i, b)).is_err() {
1101 break;
1102 }
1103 }
1104 });
1105 }
1106 drop(tx);
1107 for (i, b) in rx {
1109 asked[i] = true;
1110 let batch = &pend[groups[i].clone()];
1111 let ids: Vec<String> = batch.iter().map(|p| p.lesson.id.clone()).collect();
1112 crate::stats::count(&learn::home(), crate::stats::MODEL_CALL, session, |ev| {
1113 ev.why = "judge".into();
1114 ev.repo = repo.clone();
1115 ev.n = batch.len() as u64;
1116 ev.usd = b.pred.as_ref().map_or(0.0, |p| p.cost);
1117 ev.dur_ms = b.ms;
1118 if b.pred.is_none() {
1119 ev.text = "no answer".into();
1120 }
1121 });
1122 let fields = format!(" batch={}/{n} budget={}{draw} cut={}", i + 1, budget.chars, if b.cut { cut.word() } else { "none" });
1123 let Some(pred) = b.pred else {
1124 log_cost(session, &format!("judge-failed pending={} chars={} model={label} at={}{fields}", batch.len(), b.chars, outcome::AT_END));
1126 note_unjudged(session, &ids, "failed");
1127 continue;
1128 };
1129 call += 1;
1130 record_verdicts(session, &repo, outcome::AT_END, batch, &pred, &Logged { call, chars: b.chars, model: label.clone(), fields });
1131 append(session, &format!("{END_JUDGED}{}", ids.join(",")));
1132 done += ids.len();
1133 }
1134 });
1135 if !lost.load(Ordering::SeqCst) {
1137 let unasked: Vec<String> =
1138 groups.iter().zip(&asked).filter(|(_, a)| !**a).flat_map(|(r, _)| pend[r.clone()].iter().map(|p| p.lesson.id.clone())).collect();
1139 note_unjudged(session, &unasked, "failed");
1140 }
1141 done
1142}
1143
1144fn batches(pending: &[predict::Pending], budget: usize) -> Vec<std::ops::Range<usize>> {
1150 let room = budget / END_LESSONS_SHARE;
1151 let mut out = Vec::new();
1152 let mut start = 0;
1153 for end in 2..=pending.len() {
1154 if end - start > 1 && predict::judge_section(&pending[start..end]).chars().count() > room {
1155 out.push(start..end - 1);
1156 start = end - 1;
1157 }
1158 }
1159 if start < pending.len() {
1160 out.push(start..pending.len());
1161 }
1162 out
1163}
1164
1165struct Batch {
1168 chars: usize,
1169 cut: bool,
1170 pred: Option<predict::Prediction>,
1171 ms: u64,
1172}
1173
1174fn ask_batch(batch: &[predict::Pending], sources: &[Source], budget: usize, cut: Cut, model: &str) -> Batch {
1178 let fixed = predict::judge_prompt("", batch).chars().count();
1179 let ids: Vec<&str> = batch.iter().map(|p| p.lesson.id.as_str()).collect();
1180 let anchors: Vec<Option<usize>> = sources.iter().map(|s| s.anchor(&ids)).collect();
1181 let (work, was_cut) = within(sources, &anchors, budget.saturating_sub(fixed), cut);
1182 let chars = fixed + work.chars().count();
1183 let started = std::time::Instant::now();
1184 let pred = predict::judge(&work, batch, model, end_timeout());
1185 Batch { chars, cut: was_cut, pred, ms: started.elapsed().as_millis() as u64 }
1186}
1187
1188struct Source {
1192 label: &'static str,
1193 text: Vec<char>,
1194 marks: Vec<(usize, Vec<String>)>,
1195}
1196
1197impl Source {
1198 fn of<'t>(label: &'static str, text: &'t str, keep: impl Fn(&'t str) -> Option<&'t str>, pending: &[predict::Pending]) -> Source {
1202 let mut out = Source { label, text: Vec::new(), marks: Vec::new() };
1203 let mut run: Vec<&str> = Vec::new();
1204 for l in text.lines() {
1205 if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
1206 out.push(&run, pending);
1207 run.clear();
1208 out.marks.push((out.text.len() + usize::from(!out.text.is_empty()), ids_in(list).map(str::to_string).collect()));
1210 } else if let Some(kept) = keep(l) {
1211 run.push(kept);
1212 }
1213 }
1214 out.push(&run, pending);
1215 if out.text.iter().all(|c| c.is_whitespace()) {
1216 out.text.clear();
1217 }
1218 out
1219 }
1220
1221 fn push(&mut self, lines: &[&str], pending: &[predict::Pending]) {
1223 if lines.is_empty() {
1224 return;
1225 }
1226 if !self.text.is_empty() {
1227 self.text.push('\n');
1228 }
1229 self.text.extend(without_declarations(&lines.join("\n"), pending).chars());
1230 }
1231
1232 fn anchor(&self, ids: &[&str]) -> Option<usize> {
1235 ids.iter().filter_map(|id| self.marks.iter().rev().find(|(_, l)| l.iter().any(|x| x == id)).map(|m| m.0)).min()
1236 }
1237}
1238
1239fn session_sources(session: &str, state: &str, pending: &[predict::Pending], cap: usize) -> Vec<Source> {
1246 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1247 let said = harness::session_texts(harness::current(), session, cap);
1248 vec![
1249 Source::of("The engineer's prompts and the agent's touches", state, |l| (!l.starts_with("#borg-")).then_some(l), pending),
1250 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),
1251 Source { label: "The agent's messages", text: without_declarations(&said, pending).chars().collect(), marks: Vec::new() },
1252 ]
1253}
1254
1255fn within(sources: &[Source], anchors: &[Option<usize>], budget: usize, cut: Cut) -> (String, bool) {
1261 let len: Vec<usize> = sources.iter().map(|s| s.text.len()).collect();
1262 let mut order: Vec<usize> = (0..sources.len()).filter(|i| len[*i] > 0).collect();
1263 let frame: usize = order.iter().map(|&i| sources[i].label.chars().count() + 6).sum();
1265 let total: usize = len.iter().sum();
1266 let mut left = if frame + total <= budget { total } else { budget.saturating_sub(frame + 2 * order.len()) };
1267 order.sort_by_key(|i| len[*i]);
1268 let mut keep = vec![0; sources.len()];
1269 for (k, &i) in order.iter().enumerate() {
1270 keep[i] = len[i].min(left / (order.len() - k));
1271 left -= keep[i];
1272 }
1273 let (mut out, mut was_cut) = (String::new(), false);
1274 for (i, s) in sources.iter().enumerate() {
1275 let n = keep[i];
1276 if n == 0 {
1277 continue;
1278 }
1279 let text = if n < len[i] {
1280 was_cut = true;
1281 cut.window(&s.text, n, anchors.get(i).copied().flatten())
1282 } else {
1283 s.text.iter().collect()
1284 };
1285 out.push_str(&format!("### {}\n{text}\n", s.label));
1286 }
1287 (out, was_cut)
1288}
1289
1290fn where_repo(session: &str) -> String {
1293 let text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
1294 text.lines().map(str::trim).rfind(|l| !l.is_empty() && !l.contains(['/', ' '])).unwrap_or_default().to_string()
1295}
1296
1297pub(crate) fn listed<'a>(context: &'a str, marker: &str) -> std::collections::HashSet<&'a str> {
1299 context.lines().filter_map(|l| l.strip_prefix(marker)).flat_map(ids_in).collect()
1300}
1301
1302fn ids_in(list: &str) -> impl Iterator<Item = &str> {
1304 tagged(list).0.split(',').map(str::trim).filter(|i| !i.is_empty())
1305}
1306
1307fn tag(check: &str) -> String {
1309 if check.is_empty() { String::new() } else { format!(" @{check}") }
1310}
1311
1312const MAX_EARLY_DELIVERIES: usize = 3;
1329
1330fn early(h: &dyn Harness, so_far: &SoFar, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<String> {
1331 if text.trim().is_empty()
1332 || std::env::var("BORG_EARLY").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
1333 || std::env::var_os("BORG_ONLY").is_some()
1334 || std::env::var_os("BORG_CRITIC").is_some()
1335 {
1336 return None;
1337 }
1338 let challenges = lessons(session);
1339 let context = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1340 let plain = h.caps().early_delivery && context.lines().filter(|l| l.starts_with("#borg-early ")).count() < MAX_EARLY_DELIVERIES;
1343 let standing = challenges.iter().any(conscience::Challenge::standing);
1344 if !standing && (!plain || challenges.iter().all(|c| c.scope.is_empty() && !c.consolidated)) {
1345 return None;
1346 }
1347 let settled = listed(&context, "#borg-topic ");
1348 let named = conscience::named_scopes(&challenges, &where_now(session));
1349 let mut topics: Vec<String> = if plain { named.iter().filter(|t| !settled.contains(t.as_str())).cloned().collect() } else { Vec::new() };
1350 if plain
1352 && topics.is_empty()
1353 && hook_event == "UserPromptSubmit"
1354 && !settled.contains(conscience::ANY)
1355 && challenges.iter().any(|c| c.consolidated && !c.standing())
1356 {
1357 topics.push(conscience::ANY.into());
1358 }
1359 let mut standing_topics = named;
1361 if hook_event == "UserPromptSubmit" {
1362 standing_topics.push(conscience::ANY.into());
1363 }
1364 let done = had_in(&context);
1365 let mut pool = if standing { conscience::standing_pool(&challenges, &standing_topics, &done) } else { Vec::new() };
1366 if !topics.is_empty() {
1367 pool.extend(conscience::early_pool(&challenges, &conscience::work_text(&context), &topics, &done));
1368 }
1369 let mut shown_topics = topics.clone();
1371 for t in standing_topics.into_iter().filter(|t| pool.iter().any(|(_, c)| c.standing() && c.scope.contains(t))) {
1372 if !shown_topics.contains(&t) {
1373 shown_topics.push(t);
1374 }
1375 }
1376 let selected: Vec<&conscience::Challenge> = conscience::explored(pool.iter().copied()).into_iter().take(conscience::EARLY_CAP).collect();
1379 let shown = conscience::render_early(&selected, &shown_topics);
1380 let delivered = &selected[..shown.as_ref().map_or(0, |(_, n)| *n)];
1381 let now_settled: Vec<&str> = topics
1384 .iter()
1385 .filter(|t| {
1386 !delivered.is_empty()
1388 || !challenges.iter().any(|c| {
1389 !c.standing()
1390 && (c.scope.contains(t) || (t.as_str() == conscience::ANY && c.consolidated))
1391 && !done.contains(c.id.as_str())
1392 && !delivered.iter().any(|d| d.id == c.id)
1393 })
1394 })
1395 .map(String::as_str)
1396 .collect();
1397 if !now_settled.is_empty() {
1398 append(session, &format!("#borg-topic {}", now_settled.join(",")));
1399 }
1400 let (text, _) = shown?;
1401 let ids = delivered.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
1402 let check = crate::select::check_id(session, "early", learn::now_ms(), std::process::id());
1404 let line = format!("#borg-early {ids} @{check}");
1405 append(session, &line);
1406 note_work(session, so_far, &line);
1407 let p = conscience::early_propensity(&pool, session, &shown_topics);
1408 let p: Vec<f64> = delivered.iter().map(|c| p.get(&c.id).copied().unwrap_or(0.0)).collect();
1409 record_delivery(session, input, delivered, "early", &check, p, early_rel(&challenges, &conscience::work_text(&context), &topics, delivered));
1410 log_cost(session, &format!("free event={hook_event} lessons={} chars={}", delivered.len(), text.len()));
1411 crate::stats::count(&learn::home(), crate::stats::EARLY, session, |e| {
1412 e.n = delivered.len() as u64;
1413 e.lessons = delivered.iter().map(|c| c.id.clone()).collect();
1414 e.channel = "early".into();
1415 });
1416 Some(text)
1417}
1418
1419fn early_rel(challenges: &[conscience::Challenge], work: &str, topics: &[String], delivered: &[&conscience::Challenge]) -> Vec<f64> {
1425 let Some(&(top, _)) = conscience::early_pool(challenges, work, topics, &std::collections::HashSet::new()).first() else { return Vec::new() };
1426 let ranked = conscience::rank(challenges, work);
1427 delivered.iter().map(|c| outcome::round_p(ranked.iter().find(|(_, x)| x.id == c.id).map_or(0.0, |(s, _)| *s) / top)).collect()
1428}
1429
1430fn note_work(session: &str, so_far: &SoFar, lines: &str) {
1434 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
1435 let _ = writeln!(f, "{WORK}{}\n{lines}", one_line(so_far.get(), EVIDENCE_CHARS));
1436 }
1437}
1438
1439fn record_delivery(session: &str, input: &Value, delivered: &[&conscience::Challenge], channel: &str, check: &str, p: Vec<f64>, rel: Vec<f64>) {
1445 if !learning() || delivered.is_empty() {
1446 return;
1447 }
1448 let lessons: Vec<String> = delivered.iter().map(|c| c.id.clone()).collect();
1449 let (grades, ranks, applied) = graded_of(session, &lessons);
1450 let asked = outcome::Outcome {
1451 kind: outcome::ASKED.into(),
1452 session: session.into(),
1453 lessons,
1454 ms: learn::now_ms(),
1455 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
1456 groups: conscience::disagreement_ids(delivered),
1457 channel: channel.into(),
1458 grades,
1459 ranks,
1460 applied,
1461 p: p.into_iter().map(outcome::round_p).collect(),
1462 check: check.into(),
1463 rel,
1464 ..Default::default()
1465 };
1466 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
1467}
1468
1469fn record_route(session: &str, input: &Value, check: &str, lessons: Vec<String>, channel: &str, route: &str, reason: &str) {
1472 if !learning() || lessons.is_empty() {
1473 return;
1474 }
1475 let o = outcome::Outcome {
1476 kind: outcome::DELIVERY.into(),
1477 session: session.into(),
1478 lessons,
1479 ms: learn::now_ms(),
1480 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
1481 channel: channel.into(),
1482 check: check.into(),
1483 route: route.into(),
1484 reason: reason.into(),
1485 ..Default::default()
1486 };
1487 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &o);
1488}
1489
1490fn record_routes(session: &str, input: &Value, routed: &[(&conscience::Challenge, &str)], channel: &str, route: &str, reason: &str) {
1492 for (check, ids) in by_check(routed) {
1493 record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, route, reason);
1494 }
1495}
1496
1497fn by_check<'a>(routed: &[(&'a conscience::Challenge, &'a str)]) -> Vec<(&'a str, Vec<&'a str>)> {
1499 let mut out: Vec<(&str, Vec<&str>)> = Vec::new();
1500 for (c, check) in routed {
1501 match out.iter_mut().find(|(k, _)| k == check) {
1502 Some((_, ids)) => ids.push(c.id.as_str()),
1503 None => out.push((check, vec![c.id.as_str()])),
1504 }
1505 }
1506 out
1507}
1508
1509fn queued_file(session: &str) -> PathBuf {
1516 state_file(session).with_extension("queued")
1517}
1518
1519#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1521enum Queued {
1522 Ask,
1525 Instead,
1527 Free,
1528 Placebo,
1529}
1530
1531const INSTEAD_NEXT: &str = "instead-next ";
1537
1538const ASK_NEXT: [&str; 2] = ["live ", "live-next "];
1546
1547const LEGACY_INSTEAD: &str = "instead ";
1550
1551fn queue_line(line: &str) -> (Queued, Vec<&str>, &str) {
1553 let ask = ASK_NEXT.iter().find_map(|p| line.strip_prefix(p));
1554 let instead = line.strip_prefix(INSTEAD_NEXT).or_else(|| line.strip_prefix(LEGACY_INSTEAD));
1555 let (route, list) = match (ask, line.strip_prefix("placebo "), instead) {
1556 (Some(l), _, _) => (Queued::Ask, l),
1557 (_, Some(l), _) => (Queued::Placebo, l),
1558 (_, _, Some(l)) => (Queued::Instead, l),
1559 _ => (Queued::Free, line),
1560 };
1561 (route, ids_in(list).collect(), tagged(list).1.unwrap_or_default())
1562}
1563
1564fn enqueue_all(session: &str, lines: &[String], offset: Option<usize>) -> bool {
1569 if lines.is_empty() {
1570 return true;
1571 }
1572 let q = queued_file(session);
1573 live::locked(&q, || {
1574 if offset.is_some_and(|o| gone(session, o)) {
1575 return false;
1576 }
1577 let text: String = lines.iter().map(|l| format!("{l}\n")).collect();
1578 let _ = std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(text.as_bytes()));
1579 true
1580 })
1581}
1582
1583fn take_queue(session: &str) -> Option<String> {
1586 let q = queued_file(session);
1587 let taken = state_file(session).with_extension(format!("queued.{}", std::process::id()));
1588 live::locked(&q, || std::fs::rename(&q, &taken)).ok()?;
1589 let text = std::fs::read_to_string(&taken).unwrap_or_default();
1590 let _ = std::fs::remove_file(&taken);
1591 Some(text)
1592}
1593
1594fn waiting_in(text: &str, regraded: bool) -> impl Iterator<Item = &str> {
1601 text.lines().map(queue_line).filter(move |(route, _, _)| *route != Queued::Placebo && !(regraded && *route == Queued::Ask)).flat_map(|(_, ids, _)| ids)
1602}
1603
1604fn supersede(session: &str, input: &Value, shown: &[&conscience::Challenge], asks: &[&conscience::Challenge]) -> Vec<String> {
1610 let q = queued_file(session);
1611 let mut left: Vec<(String, Vec<String>, &'static str, &'static str)> = Vec::new();
1613 live::locked(&q, || {
1614 let Ok(text) = std::fs::read_to_string(&q) else { return };
1615 let mut kept = String::new();
1616 for line in text.lines() {
1617 let (route, ids, check) = queue_line(line);
1618 let again: Vec<&str> = ids.iter().copied().filter(|i| route == Queued::Ask && shown.iter().any(|c| c.id == *i)).collect();
1619 if again.is_empty() {
1620 kept.push_str(&format!("{line}\n"));
1621 continue;
1622 }
1623 let (asked, settled): (Vec<&str>, Vec<&str>) = again.iter().copied().partition(|i| asks.iter().any(|c| c.id == *i));
1624 for (ids, route, why) in [(asked, outcome::ROUTE_SUPERSEDED, "superseded"), (settled, outcome::ROUTE_DROPPED_DONE, "settled by later work")] {
1625 if !ids.is_empty() {
1626 left.push((check.to_string(), ids.into_iter().map(String::from).collect(), route, why));
1627 }
1628 }
1629 let rest: Vec<&str> = ids.into_iter().filter(|i| !again.contains(i)).collect();
1630 if let Some(prefix) = ASK_NEXT.iter().find(|p| line.starts_with(**p))
1631 && !rest.is_empty()
1632 {
1633 kept.push_str(&format!("{prefix}{}{}\n", rest.join(","), tag(check)));
1634 }
1635 }
1636 if kept.is_empty() {
1637 let _ = std::fs::remove_file(&q);
1638 return;
1639 }
1640 let tmp = q.with_extension(format!("queued.{}.new", std::process::id()));
1641 if std::fs::write(&tmp, &kept).and_then(|()| std::fs::rename(&tmp, &q)).is_err() {
1642 let _ = std::fs::remove_file(&tmp);
1643 left.clear();
1644 }
1645 });
1646 let mut out = Vec::new();
1647 for (check, ids, route, why) in left {
1648 out.extend(ids.iter().cloned());
1649 record_route(session, input, &check, ids, "live", route, why);
1650 }
1651 out
1652}
1653
1654const PROPOSAL: &str = "#borg-proposal ";
1659
1660const LATE_EXCERPT: usize = 160;
1663
1664fn note_proposal(session: &str, check: &str, proposal: &str) {
1665 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
1666 let _ = writeln!(f, "{PROPOSAL}{check} {}", one_line(proposal, EVIDENCE_CHARS));
1667 }
1668}
1669
1670fn proposal_of<'e>(edits: &'e str, check: &str) -> Option<&'e str> {
1673 if check.is_empty() {
1674 return None;
1675 }
1676 edits.lines().rev().find_map(|l| l.strip_prefix(PROPOSAL)?.strip_prefix(check)?.strip_prefix(' '))
1677}
1678
1679fn mark_late(session: &str, input: &Value, check: &str, members: &[&conscience::Challenge], said: &str, event: &str, chars: usize) {
1686 let list = members.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
1687 let via = if event == WAKE_EVENT { format!(" via={WAKE_EVENT}") } else { String::new() };
1688 let marker = format!("#borg-asked {list}{}\n#borg-live {list} ms={}{via}", tag(check), learn::now_ms());
1689 append(session, &marker);
1690 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
1691 let _ = writeln!(f, "{said}\n{marker}");
1692 }
1693 record_asked(session, input, members, "live", check);
1694 log_cost(session, &format!("late event={event} questions={} chars={chars}", members.len()));
1695 crate::stats::count(&learn::home(), crate::stats::LIVE_ASK, session, |e| {
1696 e.n = members.len() as u64;
1697 e.lessons = members.iter().map(|c| c.id.clone()).collect();
1698 e.channel = "live".into();
1699 e.why = if event == WAKE_EVENT { WAKE_EVENT.into() } else { String::new() };
1700 });
1701}
1702
1703const WAKE_EVENT: &str = "wake";
1706
1707#[must_use]
1710pub(crate) fn lesson_wake_id(check: &str, lesson: &str) -> String {
1711 format!("L@{check}:{lesson}")
1712}
1713
1714#[must_use]
1716pub(crate) fn lesson_of_wake_id(id: &str) -> Option<(&str, &str)> {
1717 id.strip_prefix("L@")?.split_once(':').filter(|(check, lesson)| !check.is_empty() && !lesson.is_empty())
1718}
1719
1720fn reserved_file(session: &str) -> PathBuf {
1724 state_file(session).with_extension("queued.wake")
1725}
1726
1727#[must_use]
1731pub(crate) fn asks_waiting(session: &str) -> Vec<(String, Vec<String>)> {
1732 let text = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1733 if !text.lines().any(|l| queue_line(l).0 == Queued::Ask) {
1734 return Vec::new();
1735 }
1736 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1737 let mut out: Vec<(String, Vec<String>)> = Vec::new();
1738 for (route, ids, check) in text.lines().map(queue_line) {
1739 if route != Queued::Ask || proposal_of(&edits, check).is_none() {
1740 continue;
1741 }
1742 match out.iter_mut().find(|(c, _)| c == check) {
1743 Some((_, have)) => {
1744 for id in ids {
1745 if !have.iter().any(|h| h == id) {
1746 have.push(id.to_string());
1747 }
1748 }
1749 }
1750 None => out.push((check.to_string(), ids.into_iter().map(String::from).collect())),
1751 }
1752 }
1753 out
1754}
1755
1756#[must_use]
1766pub(crate) fn reserve_asks(session: &str, wanted: &[(String, Vec<String>)]) -> Option<(String, Vec<String>)> {
1767 if crate::check_async::running(session) {
1768 return None;
1769 }
1770 let all = lessons(session);
1771 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1772 let done = done_in(&state, &early_acted(session, &all, "", None));
1773 let ok =
1774 |check: &str, id: &str| wanted.iter().any(|(c, ids)| c == check && ids.iter().any(|i| i == id)) && !done.contains(id) && all.iter().any(|c| c.id == id);
1775 let q = queued_file(session);
1776 let mut taken: Vec<(String, Vec<String>)> = Vec::new();
1777 live::locked(&q, || {
1778 let Ok(text) = std::fs::read_to_string(&q) else { return };
1779 let mut kept = String::new();
1780 let mut moved = String::new();
1781 let mut once: std::collections::HashSet<&str> = std::collections::HashSet::new();
1784 for line in text.lines() {
1785 let (route, ids, check) = queue_line(line);
1786 let (mine, rest): (Vec<&str>, Vec<&str>) = ids.iter().copied().partition(|i| route == Queued::Ask && ok(check, i) && once.insert(*i));
1787 let Some(prefix) = ASK_NEXT.iter().find(|p| line.starts_with(**p)).filter(|_| !mine.is_empty()) else {
1788 kept.push_str(&format!("{line}\n"));
1789 continue;
1790 };
1791 moved.push_str(&format!("{prefix}{}{}\n", mine.join(","), tag(check)));
1792 if !rest.is_empty() {
1793 kept.push_str(&format!("{prefix}{}{}\n", rest.join(","), tag(check)));
1794 }
1795 taken.push((check.to_string(), mine.into_iter().map(String::from).collect()));
1796 }
1797 if taken.is_empty() {
1798 return;
1799 }
1800 let r = reserved_file(session);
1801 let wrote = std::fs::OpenOptions::new().create(true).append(true).open(&r).and_then(|mut f| f.write_all(moved.as_bytes()));
1802 let tmp = q.with_extension(format!("queued.{}.new", std::process::id()));
1803 let swapped =
1804 wrote.and_then(|()| if kept.is_empty() { std::fs::remove_file(&q) } else { std::fs::write(&tmp, &kept).and_then(|()| std::fs::rename(&tmp, &q)) });
1805 if swapped.is_err() {
1806 let _ = std::fs::remove_file(&tmp);
1808 let _ = unreserve(&r, &moved);
1809 taken.clear();
1810 }
1811 });
1812 if taken.is_empty() {
1813 return None;
1814 }
1815 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1816 let mut parts = Vec::new();
1817 let mut wake = Vec::new();
1818 for (check, ids) in &taken {
1819 let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| all.iter().find(|c| &c.id == i)).collect();
1820 parts.push(conscience::render_late(&members, &one_line(proposal_of(&edits, check).unwrap_or_default(), LATE_EXCERPT)));
1821 wake.extend(ids.iter().map(|id| lesson_wake_id(check, id)));
1822 }
1823 Some((parts.join("\n\n"), wake))
1824}
1825
1826fn unreserve(r: &Path, moved: &str) -> std::io::Result<()> {
1828 let text = std::fs::read_to_string(r).unwrap_or_default();
1829 let rest = text.strip_suffix(moved).unwrap_or(&text);
1830 if rest.is_empty() { std::fs::remove_file(r) } else { std::fs::write(r, rest) }
1831}
1832
1833fn take_reserved(session: &str, ids: Option<&[String]>, then: impl FnOnce(&[(String, Vec<String>)]) -> bool) -> Vec<(String, Vec<String>)> {
1841 let q = queued_file(session);
1842 let r = reserved_file(session);
1843 let named = |check: &str, id: &str| ids.is_none_or(|ids| ids.iter().any(|w| lesson_of_wake_id(w) == Some((check, id))));
1844 let mut taken: Vec<(String, Vec<String>)> = Vec::new();
1845 live::locked(&q, || {
1846 let Ok(text) = std::fs::read_to_string(&r) else { return };
1847 let mut kept = String::new();
1848 for line in text.lines() {
1849 let (_, ids, check) = queue_line(line);
1850 let (mine, rest): (Vec<&str>, Vec<&str>) = ids.iter().copied().partition(|i| named(check, i));
1851 if !rest.is_empty() {
1852 kept.push_str(&format!("{}{}{}\n", ASK_NEXT[0], rest.join(","), tag(check)));
1853 }
1854 if !mine.is_empty() {
1855 taken.push((check.to_string(), mine.into_iter().map(String::from).collect()));
1856 }
1857 }
1858 if taken.is_empty() {
1859 return;
1860 }
1861 if !then(&taken) {
1862 taken.clear();
1863 return;
1864 }
1865 let _ = if kept.is_empty() { std::fs::remove_file(&r) } else { std::fs::write(&r, &kept) };
1866 });
1867 taken
1868}
1869
1870pub(crate) fn commit_asks(session: &str, cwd: &str, ids: &[String]) -> Vec<String> {
1875 let all = lessons(session);
1876 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1877 let input = json!({"session_id": session, "cwd": cwd});
1878 let mut out = Vec::new();
1879 take_reserved(session, Some(ids), |taken| {
1880 for (check, ids) in taken {
1881 let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| all.iter().find(|c| &c.id == i)).collect();
1882 let said = proposal_of(&edits, check).unwrap_or_default();
1883 let chars = conscience::render_late(&members, &one_line(said, LATE_EXCERPT)).len();
1884 mark_late(session, &input, check, &members, said, WAKE_EVENT, chars);
1885 out.extend(members.iter().map(|c| c.id.clone()));
1886 }
1887 true
1888 });
1889 out
1890}
1891
1892pub(crate) fn release_asks(session: &str, ids: Option<&[String]>) -> Vec<String> {
1897 let q = queued_file(session);
1898 let taken = take_reserved(session, ids, |taken| {
1899 let lines: String = taken.iter().map(|(check, ids)| format!("{}{}{}\n", ASK_NEXT[0], ids.join(","), tag(check))).collect();
1901 std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(lines.as_bytes())).is_ok()
1902 });
1903 taken.iter().flat_map(|(check, ids)| ids.iter().map(|id| lesson_wake_id(check, id))).collect()
1904}
1905
1906fn reclaim_reserved(session: &str) {
1911 if reserved_file(session).exists() && !crate::listen::waiter_runs(&learn::home(), session) {
1912 release_asks(session, None);
1913 }
1914}
1915
1916fn note_never_delivered(session: &str, input: &Value) {
1920 let text = live::locked(&queued_file(session), || {
1923 std::fs::read_to_string(queued_file(session)).unwrap_or_default() + &std::fs::read_to_string(reserved_file(session)).unwrap_or_default()
1924 });
1925 for line in text.lines() {
1926 let (route, ids, check) = queue_line(line);
1927 let channel = match route {
1928 Queued::Ask => "live",
1929 Queued::Instead | Queued::Free => "queued",
1930 Queued::Placebo => outcome::PLACEBO,
1931 };
1932 record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, outcome::ROUTE_NEVER, "session ended");
1933 }
1934}
1935
1936fn tagged_p(state: &str, prefix: &str, check: &str, ids: &[&str]) -> Vec<f64> {
1939 let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
1940 for line in state.lines().filter_map(|l| l.strip_prefix(prefix)) {
1941 let (items, at) = tagged(line);
1942 if at != Some(check) {
1943 continue;
1944 }
1945 for (id, p) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
1946 if let Ok(p) = p.trim().parse::<f64>() {
1947 seen.insert(id.trim(), p);
1948 }
1949 }
1950 }
1951 ids.iter().map(|i| seen.get(i).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
1952}
1953
1954type Pairs<'a> = Vec<(&'a conscience::Challenge, &'a str)>;
1956
1957type Drop<'a> = (&'a str, &'static str, &'static str, &'static str);
1960
1961fn queued(so_far: &SoFar, hook_event: &str, session: &str, input: &Value) -> Option<String> {
1976 reclaim_reserved(session);
1977 let text = take_queue(session)?;
1978 let all = lessons(session);
1979 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1980 let had = had_in(&state);
1981 let mut done: Option<std::collections::HashSet<&str>> = None;
1982 let mut lines: Vec<(Queued, Vec<&str>, &str)> = text.lines().map(queue_line).collect();
1983 lines.sort_by_key(|l| l.0);
1986 let placebos = lines.iter().position(|l| l.0 == Queued::Placebo).unwrap_or(lines.len());
1987 let edits = if lines.iter().any(|l| l.0 == Queued::Ask) { std::fs::read_to_string(edits_file(session)).unwrap_or_default() } else { String::new() };
1989 let (mut wanted, mut placebo, mut late): (Pairs, Pairs, Pairs) = (Vec::new(), Vec::new(), Vec::new());
1991 let mut dropped: Vec<(Drop, &str)> = Vec::new();
1992 let (mut owed, mut asks) = (0, 0);
1995 for (route, ids, check) in &lines[..placebos] {
1996 let channel = if *route == Queued::Ask { "live" } else { "queued" };
1997 for &id in ids {
1998 let Some(c) = all.iter().find(|c| c.id == id) else {
1999 dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
2000 continue;
2001 };
2002 let why = if wanted.iter().chain(&late).any(|(c, _)| c.id == id) {
2003 Some("queued twice")
2004 } else if *route == Queued::Ask {
2005 done.get_or_insert_with(|| done_in(&state, &early_acted(session, &all, "", None))).contains(id).then_some("done")
2006 } else {
2007 had.contains(id).then_some("given before")
2008 };
2009 match why {
2010 Some(why) => dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, why), id)),
2011 None if *route == Queued::Ask && proposal_of(&edits, check).is_some() => late.push((c, check)),
2012 None => {
2013 owed += usize::from(*route != Queued::Free);
2014 asks += usize::from(*route == Queued::Ask);
2015 wanted.push((c, check));
2016 }
2017 }
2018 }
2019 }
2020 let free: Vec<&conscience::Challenge> = wanted.iter().map(|(c, _)| *c).collect();
2021 let shown = conscience::render_owing(&free, &[], owed);
2022 let n = shown.as_ref().map_or(0, |(_, n)| *n);
2023 dropped.extend(wanted[n..].iter().map(|(c, check)| ((*check, "queued", outcome::ROUTE_DROPPED_CHARS, "budget"), c.id.as_str())));
2024 for (_, ids, check) in &lines[placebos..] {
2027 for &id in ids {
2028 let Some(c) = all.iter().find(|c| c.id == id) else {
2029 dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
2030 continue;
2031 };
2032 let why = if placebo.iter().any(|(c, _)| c.id == id) {
2033 Some("queued twice")
2034 } else {
2035 (wanted[..n].iter().chain(&late).any(|(c, _)| c.id == id) || had.contains(id)).then_some("delivered")
2036 };
2037 match why {
2038 Some(why) => dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, why), id)),
2039 None => placebo.push((c, check)),
2040 }
2041 }
2042 }
2043 let mut parts: Vec<String> = Vec::new();
2044 let mut markers: Vec<String> = Vec::new();
2045 for (check, ids) in by_check(&late) {
2049 let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| late.iter().find(|(c, _)| c.id == *i).map(|(c, _)| *c)).collect();
2050 let said = proposal_of(&edits, check).unwrap_or_default();
2051 let text = conscience::render_late(&members, &one_line(said, LATE_EXCERPT));
2052 mark_late(session, input, check, &members, said, hook_event, text.len());
2053 so_far.late.borrow_mut().extend(ids.iter().map(|id| lesson_wake_id(check, id)));
2054 parts.push(text);
2055 }
2056 if let Some((text, n)) = shown {
2057 let now = learn::now_ms();
2060 for (check, ids) in by_check(&wanted[..asks]) {
2061 let list = ids.join(",");
2062 markers.push(format!("#borg-asked {list}{}\n#borg-live {list} ms={now}", tag(check)));
2063 let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
2064 record_asked(session, input, &members, "live", check);
2065 }
2066 for (check, ids) in by_check(&wanted[asks..n]) {
2067 markers.push(format!("#borg-queued {}{}", ids.join(","), tag(check)));
2068 let delivered: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
2069 let p = if check.is_empty() { vec![1.0; ids.len()] } else { tagged_p(&state, "#borg-pf ", check, &ids) };
2072 record_delivery(session, input, &delivered, "queued", check, p, Vec::new());
2073 }
2074 record_routes(session, input, &wanted[asks..n], "queued", outcome::ROUTE_FREE, "");
2075 log_cost(session, &format!("free event={hook_event} lessons={n} chars={} graded dropped={}", text.len(), wanted.len() - n));
2076 crate::stats::count(&learn::home(), crate::stats::QUEUED_FREE, session, |e| {
2077 e.n = n as u64;
2078 e.lessons = wanted[..n].iter().map(|(c, _)| c.id.clone()).collect();
2079 e.channel = "queued".into();
2080 });
2081 parts.push(text);
2082 }
2083 for (check, ids) in by_check(&placebo) {
2085 markers.push(format!("#borg-placebo {}{}", ids.join(","), tag(check)));
2086 }
2087 record_routes(session, input, &placebo, outcome::PLACEBO, outcome::ROUTE_PLACEBO, "");
2088 if !markers.is_empty() {
2089 let markers = markers.join("\n");
2090 append(session, &markers);
2091 note_work(session, so_far, &markers);
2092 }
2093 let mut groups: Vec<(Drop, Vec<String>)> = Vec::new();
2094 for (key, id) in dropped {
2095 match groups.iter_mut().find(|(k, _)| *k == key) {
2096 Some((_, ids)) => ids.push(id.to_string()),
2097 None => groups.push((key, vec![id.to_string()])),
2098 }
2099 }
2100 for ((check, channel, route, why), ids) in groups {
2101 record_route(session, input, check, ids, channel, route, why);
2102 }
2103 (!parts.is_empty()).then(|| parts.join("\n\n"))
2104}
2105
2106fn work_so_far(h: &dyn Harness, input: &Value) -> String {
2109 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2110 harness::work_so_far(h.caps(), input, &turn_file(session), EVIDENCE_CHARS)
2111}
2112
2113struct SoFar<'a> {
2117 h: &'a dyn Harness,
2118 input: &'a Value,
2119 text: std::cell::OnceCell<String>,
2120 late: std::cell::RefCell<Vec<String>>,
2123}
2124
2125impl<'a> SoFar<'a> {
2126 fn new(h: &'a dyn Harness, input: &'a Value) -> Self {
2127 SoFar { h, input, text: std::cell::OnceCell::new(), late: std::cell::RefCell::default() }
2128 }
2129
2130 fn get(&self) -> &str {
2131 self.text.get_or_init(|| work_so_far(self.h, self.input))
2132 }
2133}
2134
2135fn turn_file(session: &str) -> PathBuf {
2138 state_file(session).with_extension("turn")
2139}
2140
2141fn queen_ask(h: &dyn Harness, session: &str, cwd: &str) -> Option<String> {
2147 if !learning() || !crate::queen::ask_enabled() || std::env::var_os("BORG_ONLY").is_some() || std::env::var_os("BORG_CRITIC").is_some() {
2148 return None;
2149 }
2150 let repos = || {
2151 let mut repos = crate::systems::work_repos(&std::fs::read_to_string(where_file(session)).unwrap_or_default());
2152 let here = repo_of(cwd);
2153 if !here.is_empty() && !repos.contains(&here) {
2154 repos.insert(0, here);
2155 }
2156 repos
2157 };
2158 let scope = if cwd.is_empty() { String::new() } else { format!(" --dir {}", crate::queen::sh_quote(cwd)) };
2159 let mut q = crate::queen::deliver(&learn::home(), session, repos, &scope, learn::now_ms())?;
2160 if let Ok(exe) = std::env::current_exe() {
2161 q = q.run_by(&crate::queen::sh_quote(&exe.display().to_string()));
2162 }
2163 let text = crate::queen::render_delivery(&q, h.caps().question_dialog);
2164 log_cost(session, &format!("free event=UserPromptSubmit queen={} chars={}", q.id, text.len()));
2165 Some(text)
2166}
2167
2168fn free_context(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<Value> {
2170 free_context_with(h, hook_event, session, input, text, None)
2171}
2172
2173fn free_context_with(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str, extra: Option<String>) -> Option<Value> {
2178 let so_far = SoFar::new(h, input);
2179 let parts: Vec<String> =
2180 [queued(&so_far, hook_event, session, input), early(h, &so_far, hook_event, session, input, text), extra].into_iter().flatten().collect();
2181 let mut out = (!parts.is_empty()).then(|| json!({"hookSpecificOutput": {"hookEventName": hook_event, "additionalContext": parts.join("\n\n")}}))?;
2182 let late = so_far.late.take();
2183 if !late.is_empty() {
2184 out["shown_ids"] = json!(late);
2185 }
2186 Some(out)
2187}
2188
2189enum Verdict {
2191 Skip(String),
2193 Failed,
2195 Ask(Vec<String>),
2197}
2198
2199struct Work<'a> {
2201 all: &'a [conscience::Challenge],
2203 judged: &'a [conscience::Challenge],
2206 challenges: &'a [conscience::Challenge],
2209 done: &'a std::collections::HashSet<&'a str>,
2210 state: &'a str,
2212 context: &'a str,
2215 scope_text: &'a str,
2216 message: &'a str,
2218 changed: bool,
2220 judge: bool,
2223 budget: Option<std::time::Duration>,
2226 started: std::time::Instant,
2227 detached: Option<std::time::Duration>,
2231 edits: Option<usize>,
2234 check: &'a str,
2237 summary: &'a crate::select::summary::Summary,
2241}
2242
2243impl Work<'_> {
2244 fn limit(&self) -> std::time::Duration {
2247 self.detached.unwrap_or_else(|| live::call_limit(self.budget, self.started.elapsed()))
2248 }
2249
2250 fn channel(&self) -> &'static str {
2253 if self.detached.is_some() { "live" } else { "stop" }
2254 }
2255}
2256
2257struct Call<'a> {
2259 eligible: usize,
2262 order: &'a [&'a conscience::Challenge],
2263 tail: &'a [(usize, &'a conscience::Challenge)],
2267 pt: f64,
2268 boundary: &'a [(usize, &'a conscience::Challenge)],
2271 budget: usize,
2273 model: &'a str,
2274 prompt_hash: String,
2275 dur_ms: u64,
2276 usd: f64,
2278 line: &'static str,
2282 moves: Vec<String>,
2283 whys: Vec<String>,
2284}
2285
2286fn check(session: &str, input: &Value, w: &Work) -> Verdict {
2295 if let Some(score) = gate(w.challenges, w.context, w.changed) {
2296 return Verdict::Skip(score);
2297 }
2298 let text = conscience::work_text(w.context);
2300 let pool = match pool(session, w, &text) {
2301 Ok(pool) => pool,
2302 Err(skip) => return skip,
2303 };
2304 let called = call(session, input, w, &pool, &text);
2305 settle(session, input, w, pool, called, None).verdict
2306}
2307
2308fn gate(challenges: &[conscience::Challenge], context: &str, changed: bool) -> Option<String> {
2312 if prefilter::edits_gate() { (!changed).then(|| "noedit".to_string()) } else { prefilter::skip(challenges, context).map(|score| format!("{score:.2}")) }
2313}
2314
2315struct Pool<'a> {
2317 shortlist: Vec<&'a conscience::Challenge>,
2319 eligible: usize,
2322 order: Vec<&'a conscience::Challenge>,
2323 tail: Vec<(usize, &'a conscience::Challenge)>,
2324 pt: f64,
2325 boundary: Vec<(usize, &'a conscience::Challenge)>,
2326 budget: usize,
2327 whole: bool,
2329 judging: Judging<'a>,
2331 rows: Option<crate::pools::Rows<'a>>,
2334 text: String,
2335}
2336
2337fn pool<'a>(session: &str, w: &Work<'a>, text: &str) -> Result<Pool<'a>, Verdict> {
2340 let started = std::time::Instant::now();
2344 let shortlist_now = || {
2345 let (list, added) = crate::vectors::shortlist(&lessons_path(), w.challenges, text, w.scope_text, crate::vectors::SHORTLIST);
2346 if crate::vectors::enabled() {
2347 log_cost(session, &format!("vectors added={added} ms={}", started.elapsed().as_millis()));
2348 }
2349 list
2350 };
2351 let whole = crate::whole::on();
2355 let (shortlist, eligible, order, tail, pt, boundary, budget, rows) = if whole {
2356 let raw = conscience::raw(w.challenges, text);
2357 let ranked = conscience::rank_raw(w.challenges, raw.clone(), w.scope_text);
2358 let eligible = crate::whole::eligible(w.challenges, &ranked, w.scope_text);
2359 let n = eligible.len();
2360 let tokens = match std::env::var_os("BORG_STOP_POOL_TOKENS") {
2364 Some(_) => crate::whole::tokens(),
2365 None => {
2366 let limit = w.limit();
2367 crate::select::pool_tokens(w.summary, Some(u64::try_from(limit.as_millis()).unwrap_or(u64::MAX)), session)
2368 }
2369 };
2370 let e = crate::whole::explore(eligible, shortlist_now, &ranked, tokens, crate::whole::boundary_share(), tail_seed(session, w.check, w.summary));
2375 let sampled = if e.tail.is_empty() { String::new() } else { format!(" tail={}", e.tail.len()) };
2376 log_cost(
2377 session,
2378 &format!(
2379 "pool mode=all eligible={n} shown={} budget={}{sampled} boundary={}",
2380 e.filled.shown.len(),
2381 if e.filled.fell { "full" } else { "fits" },
2382 e.boundary.len()
2383 ),
2384 );
2385 let rows = w.detached.map(|_| crate::pools::Rows::of(&e, w.challenges, &raw, tokens));
2387 let mut shown = e.filled.shown.clone();
2388 shown.extend(e.boundary.iter().chain(&e.tail).map(|(_, c)| *c));
2389 let crate::whole::Explored { filled, tail, pt, boundary, .. } = e;
2390 (shown, n, filled.order, tail, pt, boundary, tokens, rows)
2391 } else {
2392 let list = shortlist_now();
2393 (list.clone(), list.len(), list, Vec::new(), 0.0, Vec::new(), 0, None)
2394 };
2395 let judging = if w.judge {
2397 pending_upto(session, w.state, w.judged, w.message, When::Stop, w.check, w.edits)
2398 } else {
2399 Judging { pending: Vec::new(), question: false, crowded: Vec::new() }
2400 };
2401 if shortlist.is_empty() && !judging.question {
2404 return Err(Verdict::Skip("empty".into()));
2405 }
2406 Ok(Pool { shortlist, eligible, order, tail, pt, boundary, budget, whole, judging, rows, text: text.to_string() })
2407}
2408
2409struct Called {
2411 pred: Option<predict::Prediction>,
2412 dur_ms: u64,
2413 model: String,
2414 extra: usize,
2416 graded: bool,
2417 compact: bool,
2418}
2419
2420fn call(session: &str, input: &Value, w: &Work, pool: &Pool, text: &str) -> Called {
2423 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
2424 let pend = &pool.judging.pending;
2425 let extra = predict::judge_section(pend).len();
2426 let graded = crate::whole::graded();
2427 let compact = pool.whole && !crate::whole::classic();
2428 let timeout = w.limit();
2429 let shown_work = without_declarations(text, pend);
2432 let called = std::time::Instant::now();
2433 let pred = if compact {
2434 predict::predict_whole(&shown_work, &pool.shortlist, pend, &model, graded, timeout)
2435 } else {
2436 predict::predict(&shown_work, &pool.shortlist, pend, &model, graded, timeout)
2437 };
2438 let dur_ms = called.elapsed().as_millis() as u64;
2439 if w.detached.is_none() {
2441 note_check(session, input, pool.shortlist.len(), dur_ms, pred.as_ref().map(|p| p.cost), None);
2442 }
2443 Called { pred, dur_ms, model, extra, graded, compact }
2444}
2445
2446fn note_check(session: &str, input: &Value, shown: usize, dur_ms: u64, cost: Option<f64>, limit: Option<std::time::Duration>) {
2449 stat(session, input, crate::stats::CHECK, |e| {
2450 e.n = shown as u64;
2451 e.dur_ms = dur_ms;
2452 e.usd = cost.unwrap_or(0.0);
2453 if cost.is_none() {
2454 e.why = "failed".into();
2455 }
2456 if let Some(limit) = limit {
2457 e.mode = crate::check_async::MODE.into();
2458 e.rss_kb = crate::check_async::rss_kb();
2459 e.limit_ms = u64::try_from(limit.as_millis()).unwrap_or(u64::MAX);
2460 }
2461 });
2462}
2463
2464pub(crate) struct Late<'l> {
2468 pub offset: usize,
2469 pub gone: bool,
2470 pub proposal: &'l str,
2471 pub stop_ms: i64,
2473}
2474
2475struct Settled<'a> {
2479 verdict: Verdict,
2480 free: Vec<String>,
2481 superseded: Vec<String>,
2482 pool: Option<PoolLog<'a>>,
2483}
2484
2485struct PoolLog<'a> {
2488 rows: crate::pools::Rows<'a>,
2489 text: String,
2490 applied: Vec<(&'a conscience::Challenge, u8)>,
2491 pending: Vec<String>,
2492 line: &'static str,
2493 model: String,
2494 prompt_hash: String,
2495 failed: bool,
2496 idx: u64,
2498 stop_ms: i64,
2499}
2500
2501fn log_pool(session: &str, w: &Work, p: &PoolLog) {
2504 let delivered = had_in(w.state);
2505 let in_play: std::collections::HashSet<&str> = delivered.iter().copied().chain(applied_in(w.state)).collect();
2506 let c = crate::pools::Check {
2507 session,
2508 check: w.check,
2509 idx: p.idx,
2510 stop_ms: p.stop_ms,
2511 line: p.line,
2512 model: &p.model,
2513 prompt_hash: &p.prompt_hash,
2514 failed: p.failed,
2515 applied: &p.applied,
2516 pending: &p.pending,
2517 text: &p.text,
2518 scope_text: w.scope_text,
2519 challenges: w.challenges,
2520 delivered: &delivered,
2521 in_play: &in_play,
2522 lessons: &lessons_path(),
2523 outcomes: &graded_log(),
2524 };
2525 crate::pools::log(&learn::home(), &p.rows, &c);
2526}
2527
2528fn settle<'a>(session: &str, input: &Value, w: &Work<'a>, pool: Pool<'a>, called: Called, late: Option<&Late>) -> Settled<'a> {
2535 let Pool { shortlist, eligible, order, tail, pt, boundary, budget, judging, rows, text, .. } = pool;
2536 let Called { pred, dur_ms, model, extra, graded, compact } = called;
2537 let none = |verdict| Settled { verdict, free: Vec::new(), superseded: Vec::new(), pool: None };
2538 let usd = pred.as_ref().map_or(0.0, |p| p.cost);
2539 let line = if compact { crate::whole::line_format().name() } else { "" };
2540 let mut call = Call {
2541 eligible,
2542 order: &order,
2543 tail: &tail,
2544 pt,
2545 boundary: &boundary,
2546 budget,
2547 model: &model,
2548 prompt_hash: predict::template_hash(compact, graded),
2549 dur_ms,
2550 usd,
2551 line,
2552 moves: Vec::new(),
2553 whys: Vec::new(),
2554 };
2555 note_unjudged(session, &judging.crowded, "slots");
2556 let mut pend = judging.pending;
2557 if let Some(late) = late {
2558 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2559 let since = touched_since(state.get(late.offset..).unwrap_or_default());
2560 pend.retain(|p| !since.contains(p.lesson.id.as_str()));
2561 }
2562 let pending: Vec<String> = pend.iter().map(|p| p.lesson.id.clone()).collect();
2564 let pool_log = |applied: &[(&'a conscience::Challenge, u8)], failed: bool, prompt_hash: &str| {
2565 let (rows, late) = (rows?, late?);
2566 Some(PoolLog {
2567 rows,
2568 text,
2569 applied: applied.to_vec(),
2570 pending,
2571 line,
2572 model: model.clone(),
2573 prompt_hash: prompt_hash.to_string(),
2574 failed,
2575 idx: crate::check_async::index(session),
2576 stop_ms: late.stop_ms,
2577 })
2578 };
2579 let Some(pred) = pred else {
2580 if graded {
2582 record_graded(session, input, w, &[], None, &call);
2583 }
2584 note_unjudged(session, &pend.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>(), "failed");
2585 let pool = if graded { pool_log(&[], true, &call.prompt_hash) } else { None };
2586 return Settled { pool, ..none(Verdict::Failed) };
2587 };
2588 append(session, &format!("#borg-predict-cost {:.4}", pred.cost));
2590 let gone = late.is_some_and(|l| l.gone);
2593 if !pend.is_empty() && !gone {
2594 let call = match late {
2597 Some(_) => std::fs::read_to_string(state_file(session)).unwrap_or_default().lines().filter(|l| l.starts_with("#borg-predict-cost")).count(),
2598 None => w.state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1,
2599 };
2600 let logged = Logged { call, chars: extra, model: judge_model(&model), fields: format!(" prompt={}", pred.prompt) };
2602 record_verdicts(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()), "", &pend, &pred, &logged);
2603 }
2604 let (applied, whys): (Vec<(&conscience::Challenge, u8)>, Vec<String>) = pred
2606 .applies
2607 .iter()
2608 .zip(&pred.grades)
2609 .enumerate()
2610 .filter_map(|(at, (i, g))| shortlist.iter().find(|c| &c.id == i).map(|c| ((*c, *g), pred.whys.get(at).cloned().unwrap_or_default())))
2611 .unzip();
2612 call.moves.clone_from(&pred.moves);
2613 call.whys = whys;
2614 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
2615 if graded {
2616 let lessons = lessons_path();
2620 let at = Decide {
2623 session,
2624 check: w.check,
2625 channel: w.channel(),
2626 lessons: &lessons,
2627 tokens: crate::select::shown_tokens(call.order),
2628 recorded: records_reach_gate(&lessons),
2629 };
2630 let r = decide(crate::select::policy(), &applied, &at, w.summary, DECIDE_WAIT, |r, basis| {
2631 record_graded(session, input, w, &applied, Some((r, basis)), &call)
2632 });
2633 let pool = pool_log(&applied, false, &call.prompt_hash);
2634 let routed = |cs: &[&conscience::Challenge], p: &[f64]| -> Vec<(&str, f64)> {
2635 applied.iter().zip(p).filter(|((c, _), _)| cs.iter().any(|x| x.id == c.id)).map(|((c, _), p)| (c.id.as_str(), *p)).collect()
2636 };
2637 let mut lines = vec![graded_line(&applied), r.log.line(applied.len(), w.check)];
2638 lines.extend(r.log.attention.clone());
2639 lines.extend(p_line("#borg-p", routed(&r.ask, &r.p), Some(w.check)));
2640 lines.extend(p_line("#borg-pf", routed(&r.free, &r.pf), Some(w.check)));
2641 append(session, &lines.join("\n"));
2642 let superseded = if late.is_some() && !gone { supersede(session, input, &shortlist, &r.ask) } else { Vec::new() };
2643 let mut queue: Vec<String> = Vec::new();
2645 if let Some(late) = late
2646 && !r.ask.is_empty()
2647 {
2648 note_proposal(session, w.check, late.proposal);
2650 queue.push(format!("{}{}{}", ASK_NEXT[0], ids(&r.ask).join(","), tag(w.check)));
2651 }
2652 if !r.free.is_empty() {
2653 let asks = if r.log.policy == crate::select::Policy::Learned.name() { conscience::graded_without(&applied, None, &[]).0 } else { Vec::new() };
2657 let (owed, rest): (Vec<&conscience::Challenge>, Vec<&conscience::Challenge>) = r.free.iter().partition(|c| asks.iter().any(|a| a.id == c.id));
2658 if !owed.is_empty() {
2659 queue.push(format!("{INSTEAD_NEXT}{}{}", ids(&owed).join(","), tag(w.check)));
2660 }
2661 if !rest.is_empty() {
2662 queue.push(format!("{}{}", ids(&rest).join(","), tag(w.check)));
2663 }
2664 }
2665 if !r.placebo.is_empty() {
2666 queue.push(format!("placebo {}{}", ids(&r.placebo).join(","), tag(w.check)));
2667 }
2668 let landed = !gone && enqueue_all(session, &queue, late.map(|l| l.offset));
2672 log_cost(
2673 session,
2674 &format!(
2675 "graded applied={} ask={} free={} {}",
2676 applied.len(),
2677 r.ask.len(),
2678 r.free.len(),
2679 if landed && !r.free.is_empty() { "queued" } else { "dropped" }
2680 ),
2681 );
2682 if !landed {
2683 for (routed, channel) in [(&r.ask, "live"), (&r.free, "queued"), (&r.placebo, outcome::PLACEBO)] {
2685 record_route(session, input, w.check, ids(routed), channel, outcome::ROUTE_DROPPED_DONE, "session ended");
2686 }
2687 return Settled { pool, superseded, ..none(Verdict::Ask(Vec::new())) };
2688 }
2689 Settled { verdict: Verdict::Ask(ids(&r.ask)), free: ids(&r.free), superseded, pool }
2690 } else {
2691 let predicted: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
2694 let text = conscience::work_text(w.context);
2695 let pool = conscience::widen_pool(&conscience::rank_scoped(w.all, &text, w.scope_text), &predicted, w.done);
2696 let widened: Vec<&conscience::Challenge> =
2697 conscience::in_scope_first(conscience::explored(pool.iter().copied())).into_iter().filter(|c| w.challenges.iter().any(|x| x.id == c.id)).collect();
2698 append_p_of(session, &pool, &widened, w.check);
2699 none(Verdict::Ask(ids(&widened)))
2701 }
2702}
2703
2704fn touched_since(after: &str) -> std::collections::HashSet<&str> {
2708 let mut out = std::collections::HashSet::new();
2709 for l in after.lines() {
2710 if let Some(rest) = l.strip_prefix("#borg-judged ") {
2711 out.insert(rest.split_once('=').map_or(rest, |(id, _)| id));
2712 } else if let Some(list) = l.strip_prefix(END_JUDGED).or_else(|| DELIVERED.iter().find_map(|m| l.strip_prefix(m))) {
2713 out.extend(ids_in(list));
2714 }
2715 }
2716 out
2717}
2718
2719struct Decide<'x> {
2721 session: &'x str,
2722 check: &'x str,
2723 channel: &'static str,
2724 lessons: &'x std::path::Path,
2725 tokens: u32,
2727 recorded: bool,
2730}
2731
2732struct Basis<'s> {
2737 summary: &'s crate::select::summary::Summary,
2738 had: &'s [String],
2739 why: &'static str,
2740}
2741
2742const DECIDE_WAIT: std::time::Duration = std::time::Duration::from_millis(live::AFTER_CALL.as_millis() as u64 / 3);
2749
2750const DECIDE_POLL: std::time::Duration = std::time::Duration::from_millis(5);
2752
2753fn decide_lock(lessons: &std::path::Path) -> PathBuf {
2755 crate::select::summary::cache_path(lessons).with_extension("json.lock")
2756}
2757
2758fn lock_within(file: &std::path::Path, wait: std::time::Duration) -> Result<std::fs::File, &'static str> {
2762 let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file).map_err(|_| outcome::DECIDED_NO_LOCK)?;
2763 let until = std::time::Instant::now() + wait;
2764 loop {
2765 match lock.try_lock() {
2766 Ok(()) => return Ok(lock),
2767 Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < until => std::thread::sleep(DECIDE_POLL),
2768 Err(std::fs::TryLockError::WouldBlock) => return Err(outcome::DECIDED_BUSY),
2769 Err(std::fs::TryLockError::Error(_)) => return Err(outcome::DECIDED_NO_LOCK),
2770 }
2771 }
2772}
2773
2774fn decide<'a>(
2790 policy: crate::select::Policy,
2791 applied: &[(&'a conscience::Challenge, u8)],
2792 at: &Decide,
2793 stale: &crate::select::summary::Summary,
2794 wait: std::time::Duration,
2795 mut record: impl FnMut(&crate::select::Routed<'a>, &Basis) -> bool,
2796) -> crate::select::Routed<'a> {
2797 use crate::select::Policy;
2798 let state = std::fs::read_to_string(state_file(at.session)).unwrap_or_default();
2799 let queue = std::fs::read_to_string(queued_file(at.session)).unwrap_or_default();
2800 let reserved = std::fs::read_to_string(reserved_file(at.session)).unwrap_or_default();
2801 let mut had: Vec<String> =
2804 had_in(&state).into_iter().chain(waiting_in(&queue, at.channel == "live")).chain(waiting_in(&reserved, false)).map(str::to_string).collect();
2805 had.sort_unstable();
2806 had.dedup();
2807 let alpha = crate::select::alpha(at.lessons);
2809 let mut on = |summary: &crate::select::summary::Summary, policy: Policy, why: &'static str| {
2811 let ctx =
2812 crate::select::Ctx { session: at.session, check: at.check, channel: at.channel, lessons: at.lessons, summary, tokens: at.tokens, had: &had, alpha };
2813 let r = crate::select::graded_under(policy, applied, &ctx);
2814 let landed = record(&r, &Basis { summary, had: &had, why });
2815 (r, landed)
2816 };
2817 if policy != Policy::Learned {
2818 return on(stale, policy, "").0;
2819 }
2820 if !at.recorded {
2821 return on(stale, Policy::Fixed, outcome::DECIDED_NO_RECORD).0;
2822 }
2823 match lock_within(&decide_lock(at.lessons), wait) {
2824 Ok(_held) => {
2825 let summary = crate::select::summary::refresh(at.lessons);
2826 match on(&summary, policy, "") {
2827 (r, true) => r,
2828 (_, false) => on(&summary, Policy::Fixed, outcome::DECIDED_NO_RECORD).0,
2830 }
2831 }
2832 Err(why) => on(stale, Policy::Fixed, why).0,
2833 }
2834}
2835
2836fn records_reach_gate(lessons: &std::path::Path) -> bool {
2841 learning() && outcome::path_beside(lessons) == graded_log()
2842}
2843
2844fn graded_log() -> PathBuf {
2846 learn::home().join("outcomes.jsonl")
2847}
2848
2849fn tail_seed(session: &str, check: &str, summary: &crate::select::summary::Summary) -> u64 {
2852 let ctx = crate::select::Ctx {
2853 session,
2854 check,
2855 channel: "stop",
2856 lessons: std::path::Path::new(""),
2857 summary,
2858 tokens: 0,
2859 had: &[],
2860 alpha: crate::select::policy::ALPHA,
2861 };
2862 crate::select::seed(&ctx) ^ u64::from_be_bytes(*b"tailpool")
2863}
2864
2865fn record_graded(
2868 session: &str,
2869 input: &Value,
2870 w: &Work,
2871 applied: &[(&conscience::Challenge, u8)],
2872 decided: Option<(&crate::select::Routed, &Basis)>,
2873 call: &Call,
2874) -> bool {
2875 if !learning() {
2876 return false;
2877 }
2878 let repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
2879 let mut o = graded_outcome(session, &repo, w.check, applied, decided, w.summary, call);
2880 o.channel = w.channel().into();
2882 if w.detached.is_some() {
2883 o.run = crate::check_async::MODE.into();
2884 }
2885 outcome::append(&graded_log(), &o).is_ok()
2886}
2887
2888#[allow(clippy::too_many_arguments)] fn graded_outcome(
2900 session: &str,
2901 repo: &str,
2902 check: &str,
2903 applied: &[(&conscience::Challenge, u8)],
2904 decided: Option<(&crate::select::Routed, &Basis)>,
2905 read: &crate::select::summary::Summary,
2906 call: &Call,
2907) -> outcome::Outcome {
2908 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
2909 let rounded = |p: &[f64]| p.iter().map(|x| outcome::round_p(*x)).collect::<Vec<_>>();
2910 let (routed, basis) = (decided.map(|d| d.0), decided.map(|d| d.1));
2911 let shown: u32 = call.order.len().try_into().unwrap_or(u32::MAX);
2912 let place =
2913 |id: &str| call.order.iter().position(|x| x.id == id).or_else(|| call.boundary.iter().chain(call.tail).find(|(_, x)| x.id == id).map(|(at, _)| *at));
2914 outcome::Outcome {
2915 kind: outcome::GRADED.into(),
2916 session: session.into(),
2917 lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
2918 ms: learn::now_ms(),
2919 repo: repo.into(),
2920 channel: "stop".into(),
2921 grades: applied.iter().map(|(_, g)| *g).collect(),
2922 ranks: (0..applied.len()).map(|r| u16::try_from(r).unwrap_or(u16::MAX)).collect(),
2923 applied: u16::try_from(applied.len()).unwrap_or(u16::MAX),
2924 chosen: routed.map(|r| ids(&r.ask)).unwrap_or_default(),
2925 explore: routed.is_some_and(|r| r.log.source == "explore"),
2926 p: routed.map(|r| rounded(&r.p)).unwrap_or_default(),
2927 check: check.into(),
2928 policy: routed.map_or_else(|| crate::select::policy().name(), |r| r.log.policy).into(),
2929 fit: routed.map_or_else(|| read.fit.key.clone(), |r| r.log.fit.clone()),
2930 seen: basis.map(|b| b.summary.key()).unwrap_or_default(),
2932 source: routed.map(|r| r.log.source).unwrap_or_default().into(),
2933 free: routed.map(|r| ids(&r.free)).unwrap_or_default(),
2934 pf: routed.map(|r| rounded(&r.pf)).unwrap_or_default(),
2935 placebo: routed.map(|r| ids(&r.placebo)).unwrap_or_default(),
2936 psi: routed.map_or(0.0, |r| r.log.psi),
2937 nofree: basis.map(|b| crate::select::nofree(applied, b.had)).unwrap_or_default(),
2938 xk: routed.and_then(|r| r.log.xk),
2940 xs: routed.and_then(|r| r.log.xs),
2941 alpha: routed.and_then(|r| r.log.alpha),
2943 tail: call.tail.iter().map(|(_, c)| c.id.clone()).collect(),
2944 pt: call.pt,
2945 reason: basis.map(|b| b.why).unwrap_or_default().into(),
2946 eligible: call.eligible.try_into().unwrap_or(u32::MAX),
2947 shown,
2948 tokens: crate::select::shown_tokens(call.order),
2949 budget: call.budget.try_into().unwrap_or(u32::MAX),
2950 fill: applied
2952 .iter()
2953 .map(|(c, _)| place(&c.id))
2954 .chain(call.tail.iter().map(|(at, _)| Some(*at)))
2955 .map(|i| i.map_or(u16::MAX, |i| u16::try_from(i).unwrap_or(u16::MAX)))
2956 .collect(),
2957 model: call.model.into(),
2958 prompt_hash: call.prompt_hash.clone(),
2959 harness: std::env::var("BORG_HARNESS").unwrap_or_default(),
2960 version: env!("CARGO_PKG_VERSION").into(),
2961 dur_ms: call.dur_ms,
2962 failed: routed.is_none(),
2963 usd: call.usd,
2964 xtokens: crate::select::shown_tokens(&call.boundary.iter().chain(call.tail).map(|(_, c)| *c).collect::<Vec<_>>()),
2966 line: call.line.into(),
2967 moves: call.moves.clone(),
2968 whys: call.whys.clone(),
2969 ..Default::default()
2970 }
2971}
2972
2973pub fn dispatch(h: &dyn Harness, event: &str, input: &Value) -> Option<Value> {
2976 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2977 match event {
2978 "prompt" => on_prompt(h, session, input),
2979 "post_tool_failure" => {
2980 capture_test(session, input, true);
2981 if h.caps().deny_capture {
2982 capture_rejection(h, session, input);
2983 }
2984 None
2985 }
2986 "end" => {
2987 on_end(h, session, input);
2988 None
2989 }
2990 "post_tool" => {
2991 capture_test(session, input, false);
2992 on_post_tool(h, session, input)
2993 }
2994 "stop" => on_stop(h, session, input),
2995 _ => None,
2996 }
2997}
2998
2999fn on_prompt(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3000 let prompt = input.get("prompt").and_then(Value::as_str).unwrap_or_default();
3001 append(session, prompt);
3002 if learning() {
3003 crate::notes::remember_start(&crate::notes::state_dir(), session, input.get("cwd").and_then(Value::as_str).unwrap_or_default());
3005 crate::notes::mark(&crate::notes::state_dir(), session, "prompt");
3006 }
3007 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(prompts_file(session)) {
3009 let _ = writeln!(f, "{prompt}");
3010 }
3011 let vetoed = (learning() && crate::veto::enabled()).then(|| crate::veto::reason_in(prompt)).flatten();
3013 match &vetoed {
3014 Some(reason) => veto(h, session, input, reason),
3015 None => capture_prompt(h, session, input, prompt),
3016 }
3017 note_where(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()));
3018 let _ = std::fs::write(state_file(session).with_extension("lastprompt"), prompt);
3019 let checked = crate::update::maybe_check(learn::now_ms());
3022 let out = (h.caps().prompt_context && input.get("context") != Some(&Value::Bool(false))).then(|| {
3025 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
3026 let ask = queen_ask(h, session, cwd);
3027 let update = crate::update::notice(checked.as_ref(), &learn::home(), &state_file(session).with_extension("update"));
3030 let extra: Vec<String> = [ask, vetoed.as_deref().map(crate::veto::render), update].into_iter().flatten().collect();
3031 free_context_with(h, "UserPromptSubmit", session, input, prompt, (!extra.is_empty()).then(|| extra.join("\n\n")))
3032 });
3033 if harness::transcript(h.caps(), input).is_none() {
3035 let _ = std::fs::write(turn_file(session), "");
3036 }
3037 out.flatten()
3038}
3039
3040fn capture_test(session: &str, input: &Value, failed: bool) {
3045 if !learning() || input.get("tool_name").and_then(Value::as_str) != Some("Bash") {
3046 return;
3047 }
3048 let command = input.pointer("/tool_input/command").and_then(Value::as_str).unwrap_or_default();
3049 if !crate::notes::regress_signals::runs_tests(command) {
3050 return;
3051 }
3052 let output = crate::notes::regress_signals::tool_output(input);
3053 if !failed && output.trim().is_empty() {
3054 return;
3055 }
3056 if let Some(what) = crate::notes::regress_signals::test_outcome(command, &output, failed)
3057 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session).with_extension("tests"))
3058 {
3059 let _ = writeln!(f, "{what} {}", learn::now_ms());
3060 }
3061}
3062
3063fn on_end(h: &dyn Harness, session: &str, input: &Value) {
3064 let _ = std::fs::remove_file(denied_file(session));
3065 if !learning() {
3066 return;
3067 }
3068 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
3070 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
3071 note_never_delivered(session, input);
3075 let end = outcome::Outcome {
3076 kind: outcome::ENDED.into(),
3077 session: session.into(),
3078 ms: learn::now_ms(),
3079 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
3080 ..Default::default()
3081 };
3082 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &end);
3083 if !crate::notes::on_end(session, input)
3086 && let Ok(exe) = std::env::current_exe()
3087 {
3088 let _ = std::process::Command::new(exe)
3089 .args(["learn", "--session", session])
3090 .stdin(std::process::Stdio::null())
3091 .stdout(std::process::Stdio::null())
3092 .stderr(std::process::Stdio::null())
3093 .spawn();
3094 }
3095}
3096
3097fn on_post_tool(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3098 let _ = std::fs::remove_file(denied_file(session));
3100 let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or_default();
3101 let tool_input = input.get("tool_input").unwrap_or(&Value::Null);
3102 let touch = touched(tool_input, tool);
3103 append(session, &touch);
3104 note_where(
3106 session,
3107 &if tool == "Bash" { bash_read_paths(tool_input.get("command").and_then(Value::as_str).unwrap_or_default()).join(" ") } else { touch.clone() },
3108 );
3109 if prefilter::edits_gate() && prefilter::changes(tool, tool_input) {
3111 let _ = std::fs::OpenOptions::new().create(true).append(true).open(changes_file(session)).and_then(|mut f| writeln!(f, "{tool}"));
3112 }
3113 let edit = edit_text(tool, tool_input);
3114 if let Some(edit) = &edit
3115 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
3116 {
3117 let _ = writeln!(f, "{edit}");
3118 }
3119 if harness::transcript(h.caps(), input).is_none()
3121 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(turn_file(session))
3122 {
3123 let _ = writeln!(f, "[{tool}] {touch}");
3124 if let Some(edit) = &edit {
3125 let _ = writeln!(f, "{edit}");
3126 }
3127 }
3128 (h.caps().tool_context && !subagent(input)).then(|| free_context(h, "PostToolUse", session, input, &touch)).flatten()
3132}
3133
3134fn subagent(input: &Value) -> bool {
3137 input.get("agent_id").and_then(Value::as_str).is_some_and(|a| !a.is_empty())
3138}
3139
3140struct StopLock {
3145 budget: Option<std::time::Duration>,
3146 guard: Option<live::Guard>,
3147}
3148
3149impl StopLock {
3150 fn take(h: &dyn Harness, session: &str) -> StopLock {
3151 StopLock { budget: crate::expert_route::stop_budget(h), guard: live::acquire(&state_file(session), learn::now_ms()) }
3152 }
3153}
3154
3155fn on_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3156 let started = std::time::Instant::now();
3157 let started_ms = learn::now_ms();
3158 if learning() {
3159 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
3161 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
3162 crate::sweep::turn_end(h, session, input);
3164 }
3165 let second_round = input.get("stop_hook_active").and_then(Value::as_bool) == Some(true);
3166 if std::env::var("BORG_CRITIC").as_deref() == Ok("generic") {
3167 return (!second_round).then(|| json!({"decision": "block", "reason": conscience::GENERIC}));
3168 }
3169 let (all, withheld) = loaded(session);
3170 let judged = judged(&all, withheld);
3171 note_answer(h, session, input);
3174 let changed = std::fs::remove_file(changes_file(session)).is_ok();
3177 if second_round {
3179 return None;
3180 }
3181 stat(session, input, crate::stats::TURN_END, |_| {});
3182 if detached() {
3183 return stop_detached(h, session, input, &all, &judged, changed, started_ms);
3184 }
3185 let lock = StopLock::take(h, session);
3186 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3187 let proposal = full_proposal(h, input);
3188 let (done, mut challenges) = askable(session, &all, &judged, &state, &proposal, lock.guard.is_some(), false, None);
3189 conscience::with_corrections(&lessons_path(), &mut challenges);
3191 let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
3192 let context = format!("{state}{message}");
3193 let scope_text = format!("{}\n{message}", where_now(session));
3195 let check_id = crate::select::check_id(session, "stop", started_ms, std::process::id());
3196 let summary = crate::select::summary::refresh(&lessons_path());
3198 let work = Work {
3199 all: &all,
3200 judged: &judged,
3201 challenges: &challenges,
3202 done: &done,
3203 state: &state,
3204 context: &context,
3205 scope_text: &scope_text,
3206 message: &proposal,
3207 changed,
3208 judge: lock.guard.is_some(),
3209 budget: lock.budget,
3210 started,
3211 detached: None,
3212 edits: None,
3213 check: &check_id,
3214 summary: &summary,
3215 };
3216 let out = match stop_select(h, session, input, &work) {
3217 Ok(selected) => stop_ask(h, session, input, selected, &check_id),
3218 Err(out) => out,
3219 };
3220 drop(lock);
3221 out
3222}
3223
3224#[allow(clippy::too_many_arguments)] fn askable<'s>(
3231 session: &str,
3232 all: &[conscience::Challenge],
3233 judged: &[conscience::Challenge],
3234 state: &'s str,
3235 proposal: &str,
3236 judge: bool,
3237 regraded: bool,
3238 edits: Option<usize>,
3239) -> (std::collections::HashSet<&'s str>, Vec<conscience::Challenge>) {
3240 let acted = early_acted(session, judged, proposal, edits);
3245 let only = std::env::var_os("BORG_ONLY").is_some();
3246 let done = if only { std::collections::HashSet::new() } else { done_in(state, &acted) };
3247 let queue = if only { String::new() } else { std::fs::read_to_string(queued_file(session)).unwrap_or_default() };
3251 let reserved = if only { String::new() } else { std::fs::read_to_string(reserved_file(session)).unwrap_or_default() };
3253 let waiting: std::collections::HashSet<&str> =
3254 if only { Default::default() } else { waiting_in(&queue, regraded).chain(waiting_in(&reserved, false)).collect() };
3255 let ending: std::collections::HashSet<&str> = if judge { Default::default() } else { open_in(state) };
3259 let challenges = all
3261 .iter()
3262 .filter(|c| !done.contains(c.id.as_str()) && !waiting.contains(c.id.as_str()) && !ending.contains(c.id.as_str()) && !c.off_system)
3263 .cloned()
3264 .collect();
3265 (done, challenges)
3266}
3267
3268fn detached() -> bool {
3272 crate::check_async::on() && std::env::var_os("BORG_ONLY").is_none() && std::env::var("BORG_MATCH").as_deref() != Ok("bm25") && crate::whole::graded()
3273}
3274
3275const CHECKING: &str = "#borg-checking";
3278
3279fn stop_detached(
3285 h: &dyn Harness,
3286 session: &str,
3287 input: &Value,
3288 all: &[conscience::Challenge],
3289 judged: &[conscience::Challenge],
3290 changed: bool,
3291 started_ms: i64,
3292) -> Option<Value> {
3293 crate::check_async::sweep(session);
3294 reclaim_reserved(session);
3295 let ctx = state_file(session);
3296 let state = std::fs::read_to_string(&ctx).unwrap_or_default();
3297 let edits = std::fs::metadata(edits_file(session)).ok().and_then(|m| usize::try_from(m.len()).ok());
3299 let proposal = full_proposal(h, input);
3300 let judge = live::lock_state(&ctx, learn::now_ms(), live::stale_ms(), live::alive) != live::Lock::Held;
3302 let (_, challenges) = askable(session, all, judged, &state, &proposal, judge, true, None);
3303 let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
3304 if let Some(why) = gate(&challenges, &format!("{state}{message}"), changed) {
3305 return skipped(h, session, input, why);
3306 }
3307 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
3308 let job = crate::check_async::Job {
3309 v: crate::check_async::JOB_VERSION,
3310 session: session.into(),
3311 check: crate::select::check_id(session, "stop", started_ms, std::process::id()),
3312 stop_ms: started_ms,
3313 harness: h.name().into(),
3314 cwd: cwd.into(),
3315 offset: state.len(),
3316 message: message.into(),
3317 proposal,
3318 changed,
3319 repo: repo_of(cwd),
3320 scope: format!("{}\n{message}", where_now(session)),
3321 edits,
3322 };
3323 let written = crate::check_async::write_job(&job);
3324 append(session, &format!("{CHECKING}{}", tag(&job.check)));
3325 if !written.is_ok_and(|path| crate::check_async::spawn(&path)) {
3327 crate::check_async::not_run(&job, crate::check_async::WHY_UNSTARTED);
3328 }
3329 queen_at_stop(h, session, input)
3330}
3331
3332fn queen_at_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3334 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
3335 crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &[])
3336}
3337
3338fn skipped(h: &dyn Harness, session: &str, input: &Value, why: String) -> Option<Value> {
3340 append(session, &format!("#borg-skip {why}"));
3341 stat(session, input, crate::stats::GATE_SKIP, |e| e.why.clone_from(&why));
3342 queen_at_stop(h, session, input)
3343}
3344
3345pub(crate) fn gone(session: &str, offset: usize) -> bool {
3349 let ended = crate::presence::read(&learn::home(), session)
3350 .is_some_and(|p| crate::presence::state(&p, learn::now_ms(), crate::presence::alive) == crate::presence::State::Ended);
3351 ended || {
3352 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3353 state.get(offset..).unwrap_or_default().lines().any(|l| l.starts_with(END_JUDGED))
3354 }
3355}
3356
3357const LOCK_TRIES: usize = 3;
3360
3361pub(crate) fn run_job(job: &crate::check_async::Job) -> Option<crate::check_async::Checked> {
3369 let session = job.session.as_str();
3370 let input = json!({"session_id": session, "cwd": job.cwd});
3371 if gone(session, job.offset) {
3372 crate::check_async::not_run(job, crate::check_async::WHY_GONE);
3373 return None;
3374 }
3375 let ctx = state_file(session);
3376 let now = std::fs::read_to_string(&ctx).unwrap_or_default();
3377 let state = now.get(..job.offset).unwrap_or(&now);
3378 let (all, withheld) = loaded(session);
3379 let judged = judged(&all, withheld);
3380 let judge = live::lock_state(&ctx, learn::now_ms(), live::stale_ms(), live::alive) != live::Lock::Held;
3381 let (done, mut challenges) = askable(session, &all, &judged, state, &job.proposal, judge, true, job.edits);
3382 conscience::with_corrections(&lessons_path(), &mut challenges);
3383 let context = format!("{state}{}", job.message);
3384 let summary = crate::select::summary::refresh(&lessons_path());
3385 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
3386 let work = Work {
3387 all: &all,
3388 judged: &judged,
3389 challenges: &challenges,
3390 done: &done,
3391 state,
3392 context: &context,
3393 scope_text: &job.scope,
3394 message: &job.proposal,
3395 changed: job.changed,
3396 judge,
3397 budget: None,
3398 started: std::time::Instant::now(),
3399 detached: Some(crate::check_async::call_limit(&model)),
3400 edits: job.edits,
3401 check: &job.check,
3402 summary: &summary,
3403 };
3404 let text = conscience::work_text(&context);
3405 let pool = match pool(session, &work, &text) {
3406 Ok(pool) => pool,
3407 Err(_) => {
3408 stat(session, &input, crate::stats::GATE_SKIP, |e| {
3410 e.why = "empty".into();
3411 e.mode = crate::check_async::MODE.into();
3412 });
3413 let mut out = crate::check_async::Checked::of(job);
3414 crate::check_async::finish(session, &mut out, false);
3415 return Some(out);
3416 }
3417 };
3418 let called = call(session, &input, &work, &pool, &text);
3419 let dur_ms = called.dur_ms;
3420 let (shown, cost) = (pool.shortlist.len(), called.pred.as_ref().map(|p| p.cost));
3421 let wait = u64::try_from(live::stale_ms()).unwrap_or(u64::MAX);
3425 let guard = (0..LOCK_TRIES).find_map(|_| {
3426 while live::wait_free(&ctx, wait) == live::Lock::Held {}
3427 live::acquire(&ctx, learn::now_ms())
3428 });
3429 let late = Late { offset: job.offset, gone: gone(session, job.offset), proposal: &job.proposal, stop_ms: job.stop_ms };
3430 let s = settle(session, &input, &work, pool, called, Some(&late));
3431 let mut out = crate::check_async::Checked::of(job);
3432 out.dur_ms = dur_ms;
3433 out.superseded = s.superseded;
3434 match s.verdict {
3435 Verdict::Failed => out.failed = true,
3436 Verdict::Ask(asks) => {
3437 out.asks = asks;
3438 out.free = s.free;
3439 }
3440 Verdict::Skip(_) => {}
3441 }
3442 crate::check_async::finish(session, &mut out, true);
3443 drop(guard);
3445 if let Some(p) = &s.pool {
3446 log_pool(session, &work, p);
3447 }
3448 note_check(session, &input, shown, dur_ms, cost, work.detached);
3449 Some(out)
3450}
3451
3452fn stop_select<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
3455 match std::env::var("BORG_ONLY") {
3456 Ok(ids) => Ok(w.challenges.iter().filter(|c| ids.split(',').any(|i| i == c.id)).collect()),
3458 Err(_) if std::env::var("BORG_MATCH").as_deref() != Ok("bm25") => stop_check(h, session, input, w),
3461 Err(_) => {
3464 let pool = conscience::budget_pool(&conscience::rank_scoped(w.all, &conscience::work_text(w.context), w.scope_text), w.done);
3465 let asked: Vec<&conscience::Challenge> = conscience::in_scope_first(conscience::explored(pool.iter().copied()))
3466 .into_iter()
3467 .filter_map(|c| w.challenges.iter().find(|x| x.id == c.id))
3468 .collect();
3469 append_p_of(session, &pool, &asked, w.check);
3470 Ok(asked)
3471 }
3472 }
3473}
3474
3475fn stop_check<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
3477 match check(session, input, w) {
3478 Verdict::Skip(why) => Err(skipped(h, session, input, why)),
3479 Verdict::Failed => Err(None),
3480 Verdict::Ask(ids) => Ok(ids.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).collect()),
3481 }
3482}
3483
3484fn graded_line(applied: &[(&conscience::Challenge, u8)]) -> String {
3487 let items: Vec<String> = applied.iter().enumerate().map(|(r, (c, g))| format!("{}:{g}:{r}", c.id)).collect();
3488 format!("#borg-graded n={} {}", applied.len(), items.join(","))
3489}
3490
3491fn p_line<'a>(prefix: &str, chosen: impl IntoIterator<Item = (&'a str, f64)>, check: Option<&str>) -> Option<String> {
3497 let items: Vec<String> = chosen.into_iter().map(|(id, p)| format!("{id}={}", outcome::round_p(p))).collect();
3498 (!items.is_empty()).then(|| format!("{prefix} {}{}", items.join(","), tag(check.unwrap_or_default())))
3499}
3500
3501fn append_p_of(session: &str, pool: &[(f64, &conscience::Challenge)], asked: &[&conscience::Challenge], check: &str) {
3508 if asked.is_empty() {
3509 return;
3510 }
3511 let p = conscience::propensity(pool, session);
3512 let chosen: Vec<(&str, f64)> = asked.iter().filter_map(|c| p.get(&c.id).map(|p| (c.id.as_str(), *p))).collect();
3513 let items: Vec<String> =
3514 pool.iter().map(|(s, c)| format!("{}={}{}", c.id, conscience::pool_base(*s, c), if c.scope.is_empty() { "" } else { ":s" })).collect();
3515 let pool = format!("#borg-pool {}{}", items.join(","), tag(check));
3516 append(session, &p_line("#borg-p", chosen, Some(check)).map_or(pool.clone(), |p| format!("{p}\n{pool}")));
3517}
3518
3519fn tagged(line: &str) -> (&str, Option<&str>) {
3521 match line.rsplit_once(" @") {
3522 Some((items, check)) if !check.is_empty() && check.bytes().all(|b| b.is_ascii_hexdigit()) => (items, Some(check)),
3523 _ => (line, None),
3524 }
3525}
3526
3527fn pool_in(state: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
3532 let parse = |line: &str| -> Option<(Vec<String>, Vec<f64>, Vec<bool>)> {
3533 let mut out = (Vec::new(), Vec::new(), Vec::new());
3534 for item in line.split(',') {
3535 let (id, rest) = item.rsplit_once('=')?;
3536 let (base, scoped) = match rest.strip_suffix(":s") {
3537 Some(b) => (b, true),
3538 None => (rest, false),
3539 };
3540 out.0.push(id.trim().to_string());
3541 out.1.push(base.trim().parse::<f64>().ok()?);
3542 out.2.push(scoped);
3543 }
3544 Some(out)
3545 };
3546 let mut check: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
3548 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
3549 let (items, tag) = tagged(line);
3550 for (id, _) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
3551 check.insert(id.trim(), tag);
3552 }
3553 }
3554 let Some(Some(tag)) = ids.first().and_then(|i| check.get(i.as_str())).copied() else { return Default::default() };
3555 if !ids.iter().all(|i| check.get(i.as_str()).copied().flatten() == Some(tag)) {
3556 return Default::default();
3557 }
3558 state
3559 .lines()
3560 .rev()
3561 .filter_map(|l| l.strip_prefix("#borg-pool "))
3562 .map(tagged)
3563 .filter(|(_, t)| *t == Some(tag))
3564 .find_map(|(items, _)| parse(items).filter(|(pool, _, _)| ids.iter().all(|i| pool.contains(i))))
3565 .unwrap_or_default()
3566}
3567
3568fn propensity_in(state: &str, ids: &[String]) -> Vec<f64> {
3572 let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
3573 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
3574 for (id, p) in tagged(line).0.split(',').filter_map(|it| it.rsplit_once('=')) {
3575 if let Ok(p) = p.trim().parse::<f64>() {
3576 seen.insert(id.trim(), p);
3577 }
3578 }
3579 }
3580 ids.iter().map(|i| seen.get(i.as_str()).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
3581}
3582
3583fn graded_of(session: &str, ids: &[String]) -> (Vec<u8>, Vec<u16>, u16) {
3586 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3587 let mut seen: std::collections::HashMap<String, (u8, u16, u16)> = std::collections::HashMap::new();
3588 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")) {
3589 let Some((n, items)) = line.split_once(' ') else { continue };
3590 let n: u16 = n.parse().unwrap_or(0);
3591 for it in items.split(',') {
3592 let mut p = it.rsplitn(3, ':');
3593 let (Some(r), Some(g), Some(id)) = (p.next(), p.next(), p.next()) else { continue };
3594 seen.insert(id.to_string(), (g.parse().unwrap_or(0), r.parse().unwrap_or(0), n));
3595 }
3596 }
3597 let got: Vec<(u8, u16, u16)> = ids.iter().map(|i| seen.get(i).copied().unwrap_or((0, 0, 0))).collect();
3598 (got.iter().map(|g| g.0).collect(), got.iter().map(|g| g.1).collect(), got.iter().map(|g| g.2).max().unwrap_or(0))
3599}
3600
3601fn record_asked(session: &str, input: &Value, lessons: &[&conscience::Challenge], channel: &str, check: &str) {
3606 if !learning() || lessons.is_empty() {
3607 return;
3608 }
3609 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3610 let ids: Vec<String> = lessons.iter().map(|c| c.id.clone()).collect();
3611 let members: Vec<&str> = ids.iter().map(String::as_str).collect();
3612 let (grades, ranks, applied) = graded_of(session, &ids);
3613 let p = if check.is_empty() { propensity_in(&state, &ids) } else { tagged_p(&state, "#borg-p ", check, &members) };
3614 let (pool, base, scoped) = pool_in(&state, &ids);
3615 let asked = outcome::Outcome {
3616 kind: outcome::ASKED.into(),
3617 session: session.into(),
3618 lessons: ids.clone(),
3619 ms: learn::now_ms(),
3620 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
3621 groups: conscience::disagreement_ids(lessons),
3623 channel: channel.into(),
3624 grades,
3625 ranks,
3626 applied,
3627 p,
3628 pool,
3629 base,
3630 scoped,
3631 check: check.into(),
3632 ..Default::default()
3633 };
3634 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
3635 record_route(session, input, check, ids, channel, outcome::ROUTE_ASKED, "");
3636}
3637
3638fn stop_ask(h: &dyn Harness, session: &str, input: &Value, mut lessons: Vec<&conscience::Challenge>, check: &str) -> Option<Value> {
3641 crate::memory::shuffle(&mut lessons, session);
3644 let log = lessons.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
3645 let marker = if lessons.is_empty() { "#borg-asked ".to_string() } else { format!("#borg-asked {log}{}", tag(check)) };
3647 append(session, &marker);
3648 if !lessons.is_empty()
3649 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
3650 {
3651 let _ = writeln!(f, "{}\n{marker}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
3653 }
3654 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
3657 let queen = crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &lessons);
3658 if lessons.is_empty() {
3659 return queen;
3660 }
3661 record_asked(session, input, &lessons, "stop", check);
3662 let out = queen.or_else(|| Some(json!({"decision": "block", "reason": conscience::render(&lessons)})));
3663 let chars = out.as_ref().and_then(|o| o["reason"].as_str()).map_or(0, str::len);
3664 log_cost(session, &format!("block questions={} chars={chars}", lessons.len()));
3665 stat(session, input, crate::stats::BLOCK, |e| {
3666 e.n = lessons.len() as u64;
3667 e.lessons = lessons.iter().map(|c| c.id.clone()).collect();
3668 e.channel = "stop".into();
3669 });
3670 out
3671}
3672
3673pub fn main(name: &str, event: &str) {
3678 let Some(h) = harness::by_name(name) else { return };
3679 let mut raw = String::new();
3680 let _ = std::io::stdin().read_to_string(&mut raw);
3681 let mut input = h.normalise(serde_json::from_str(&raw).unwrap_or(Value::Null));
3682 if !h.caps().sends_pid
3684 && let Some(m) = input.as_object_mut()
3685 {
3686 m.shift_remove("pid");
3687 }
3688 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
3691 let cfg = collective::load();
3692 let route = match &cfg {
3693 Ok(cfg) => collective::route(cfg.as_ref(), cwd, &learn::home()),
3694 Err(_) => collective::Route::Off,
3695 };
3696 let collective::Route::Collective { home, name, .. } = route else { return };
3697 unsafe {
3700 std::env::set_var("BORG_HOME", &home);
3701 std::env::set_var("BORG_HARNESS", h.name());
3703 if std::env::var_os("BORG_STATE").is_none() {
3704 std::env::set_var("BORG_STATE", home.join("state"));
3705 }
3706 }
3707 let now = learn::now_ms();
3709 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
3710 crate::listen::touch(&home, event, session, now);
3714 if matches!(event, "start" | "halted") {
3716 return;
3717 }
3718 let me = match event {
3721 "pre_tool" => {
3722 if !crate::steer::active(&home) {
3723 return;
3724 }
3725 crate::presence::read(&home, session)
3726 }
3727 _ => crate::presence::touch(&home, &name, h.name(), event, &input, now),
3728 };
3729 let started = me.map(|p| p.started);
3732 let stopping = crate::listen::stopping(&home, event, session, now);
3734 let out = crate::steer::around(&home, event, &input, started, now, || crate::expert_route::checkpoint(h, event, &input, || dispatch(h, event, &input)));
3735 let out = crate::listen::after_stop(&home, h, stopping.as_ref(), &input, out);
3737 let out = harness::with_budgets(h, &input, out);
3739 if let Some(out) = crate::msg::deliver(&home, h.caps(), event, &input, started, now, out) {
3741 println!("{}", for_harness(h, out));
3742 }
3743}
3744
3745fn for_harness(h: &dyn Harness, mut out: Value) -> Value {
3749 if matches!(h.wiring(), harness::Wiring::CommandHooks { .. })
3750 && let Some(o) = out.as_object_mut()
3751 {
3752 o.shift_remove("shown_ids");
3753 }
3754 out
3755}
3756
3757#[cfg(test)]
3758mod tests {
3759 use super::*;
3760
3761 #[test]
3762 fn every_file_of_an_edit_survives_the_cut_of_its_text() {
3763 let long = "x".repeat(700);
3764 let input = json!({"file_path": "/w/a.rs", "new_string": long, "edits": [{"file_path": "/w/b.rs", "new_string": "more"}]});
3765 let text = edit_text("Edit", &input).unwrap();
3766 assert!(text.starts_with("/w/a.rs /w/b.rs x"), "{}", &text[..40]);
3767 assert_eq!(text.chars().count(), 600);
3768 let multi = json!({"file_path": "/w/a.rs", "edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "c", "new_string": "d"}]});
3769 assert_eq!(edit_text("MultiEdit", &multi).as_deref(), Some("/w/a.rs b d"), "Claude's MultiEdit reads as before");
3770 assert_eq!(touched(&input, "Edit"), "/w/a.rs /w/b.rs");
3771 }
3772
3773 #[test]
3774 fn a_waiting_ask_is_graded_again_and_a_waiting_hand_over_is_not() {
3775 let c = "0123456789ab";
3776 let queue = format!("A,B @{c}\ninstead-next C @{c}\nlive D @{c}\nlive-next E\nplacebo F @{c}\n");
3777 assert_eq!(waiting_in(&queue, true).collect::<Vec<_>>(), ["A", "B", "C"], "a detached check's");
3778 assert_eq!(waiting_in(&queue, false).collect::<Vec<_>>(), ["A", "B", "C", "D", "E"], "a stop's own check's, as before");
3779 }
3780
3781 #[test]
3782 fn a_detached_checks_questions_name_the_turn_it_judged() {
3783 let edits = "#borg-proposal 0123456789ab first try\nwork\n#borg-proposal 0123456789ab I drop the token.\n#borg-proposal 00000000000f other\n";
3784 assert_eq!(proposal_of(edits, "0123456789ab"), Some("I drop the token."), "the latest line of that check");
3785 assert_eq!(proposal_of(edits, "0123456789"), None, "a whole id only");
3786 assert_eq!(proposal_of(edits, ""), None, "a line of an older binary has no check");
3787 let l = conscience::Challenge { id: "L1".into(), question: "Is the budget drained?".into(), ..Default::default() };
3788 let text = conscience::render_late(&[&l], &one_line("I drop\n the token.", LATE_EXCERPT));
3789 assert!(text.contains("your turn that ended with \"I drop the token.…\"") && text.contains("1. [L1] Is the budget drained?"), "{text}");
3790 assert!(text.contains("`<id>: applies`") && text.contains("already settled"), "{text}");
3791 assert!(conscience::render_late(&[&l], "").contains("After your last turn"));
3792 }
3793
3794 #[test]
3795 fn propensity_in_takes_the_latest_entry_and_nothing_when_one_is_missing() {
3796 let state = "#borg-p A=1,B=0.25\n#borg-asked A,B\n#borg-p B=0.5,C=0.1\n";
3797 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3798 assert_eq!(propensity_in(state, &ids(&["B", "A"])), [0.5, 1.0]);
3799 assert_eq!(propensity_in(state, &ids(&["C"])), [0.1]);
3800 assert!(propensity_in(state, &ids(&["A", "D"])).is_empty(), "D unknown: no guess");
3801 assert!(propensity_in("", &ids(&["A"])).is_empty());
3802 }
3803
3804 #[test]
3805 fn pool_in_takes_the_pool_of_the_check_that_logged_each_p() {
3806 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";
3807 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3808 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");
3809 assert_eq!(pool_in(state, &ids(&["A"])), (ids(&["A", "B", "C"]), vec![1.5, 0.25, 2.0], vec![true, false, false]));
3810 assert_eq!(pool_in(state, &ids(&["A", "B"])), Default::default(), "A's p from check 1, B's from check 2: no pairing");
3811 assert_eq!(pool_in("", &ids(&["A"])), Default::default());
3812 assert_eq!(pool_in(state, &[]), Default::default());
3813 let graded = "#borg-p A=1\n#borg-p B=0.5 @c3\n#borg-pool A=1,B=1 @c3\n";
3815 assert_eq!(pool_in(graded, &ids(&["A"])), Default::default());
3816 assert_eq!(pool_in(graded, &ids(&["B"])).0, ids(&["A", "B"]));
3817 assert_eq!(pool_in("#borg-p A=1\n#borg-pool A=1,B=2\n", &ids(&["A"])), Default::default());
3819 }
3820
3821 #[test]
3822 fn a_queue_line_names_its_route_its_ids_and_its_check() {
3823 let c = "0123456789ab";
3824 assert_eq!(queue_line(&format!("A,B @{c}")), (Queued::Free, vec!["A", "B"], c));
3825 assert_eq!(queue_line(&format!("placebo X,Y @{c}")), (Queued::Placebo, vec!["X", "Y"], c));
3826 assert_eq!(queue_line(&format!("instead G0,G1 @{c}")), (Queued::Instead, vec!["G0", "G1"], c));
3827 assert_eq!(queue_line(&format!("instead-next G2 @{c}")), (Queued::Instead, vec!["G2"], c));
3828 assert_eq!(queue_line("A,B"), (Queued::Free, vec!["A", "B"], ""));
3830 assert_eq!(queue_line(&format!("live A @{c}")), (Queued::Ask, vec!["A"], c));
3832 assert_eq!(queue_line("live A"), (Queued::Ask, vec!["A"], ""));
3833 assert_eq!(queue_line(&format!("live-next A,B @{c}")), (Queued::Ask, vec!["A", "B"], c));
3834 assert_eq!(listed(&format!("#borg-asked A,B @{c}\n#borg-asked C\n"), "#borg-asked "), ["A", "B", "C"].into_iter().collect());
3835 }
3836
3837 #[test]
3838 fn a_tagged_propensity_is_its_checks_never_another_checks() {
3839 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";
3840 assert_eq!(tagged_p(state, "#borg-pf ", "c1", &["A", "B"]), [1.0, 0.5]);
3841 assert_eq!(tagged_p(state, "#borg-pf ", "c2", &["A"]), [0.25]);
3842 assert!(tagged_p(state, "#borg-pf ", "c2", &["A", "B"]).is_empty(), "B has none at c2: no guess");
3843 assert_eq!(tagged_p(state, "#borg-p ", "c1", &["A"]), [0.75], "#borg-p and #borg-pf are separate lines");
3844 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"));
3845 assert_eq!(p_line("#borg-p", [], Some("c1")), None);
3846 }
3847
3848 #[test]
3849 fn unjudged_says_why_each_delivered_lesson_has_no_verdict() {
3850 let c = "0123456789ab";
3851 let state = format!(
3852 "#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\
3853 #borg-queued E @{c}\n#borg-judged B=pending\n#borg-asked D\n"
3854 );
3855 let why = unjudged(&state);
3856 assert_eq!(why["A"], outcome::UNJUDGED_TRIES, "two unclear tries");
3857 assert_eq!(why["B"], outcome::UNJUDGED_SLOTS, "one try is not all of them; the last call left it out");
3858 assert_eq!(why["C"], outcome::UNJUDGED_NO_CALL);
3859 assert_eq!(why["D"], outcome::UNJUDGED_NO_CALL, "delivered again after its failed call");
3860 assert_eq!(why["E"], outcome::UNJUDGED_NO_CALL);
3861 }
3862
3863 #[test]
3864 fn the_declaration_coin_is_fixed_per_check_and_lesson_and_fair() {
3865 assert_eq!(stripped("0123456789ab", "L1"), stripped("0123456789ab", "L1"));
3866 let heads = (0..1000).filter(|i| stripped("0123456789ab", &format!("L{i}"))).count();
3867 assert!((400..600).contains(&heads), "{heads} of 1000");
3868 let checks = (0..1000).filter(|i| stripped(&format!("{i:012x}"), "L1")).count();
3869 assert!((400..600).contains(&checks), "{checks} of 1000");
3870 }
3871
3872 #[test]
3877 fn pending_gives_every_route_the_same_evidence_and_its_own_check() {
3878 let session = format!("pending-v2-{}", std::process::id());
3879 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3880 let all = [c("A"), c("B"), c("C"), c("D"), c("E")];
3881 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\
3882 edit one\n#borg-work the work so far\n#borg-queued C @0000000000c3\n#borg-placebo D @0000000000c4\nedit two\n\
3883 #borg-work later work\n#borg-asked E @0000000000c5\n";
3884 let _ = std::fs::write(edits_file(&session), edits);
3885 let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
3886 let j = pending(&session, &state, &all, "the final message", When::Stop, "00000000cafe");
3887 let got: Vec<(&str, &str, &str)> = j.pending.iter().map(|p| (p.lesson.id.as_str(), p.channel, p.check.as_str())).collect();
3888 assert_eq!(
3889 got,
3890 [
3891 ("A", "stop", "0000000000c1"),
3892 ("B", "stop", "0000000000c2"),
3893 ("C", "queued", "0000000000c3"),
3894 ("D", outcome::PLACEBO, "0000000000c4"),
3895 ("E", "stop", "0000000000c5")
3896 ]
3897 );
3898 let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
3899 assert_eq!(
3900 (p("A").before.as_str(), p("C").before.as_str(), p("D").before.as_str()),
3901 ("Plan: the blocked message", "the work so far", "the work so far")
3902 );
3903 assert_eq!(p("C").after, "work since: edit two\nthis turn: the final message");
3904 assert_eq!(p("D").after, p("C").after, "a placebo is judged as a free delivery");
3905 assert!(p("A").after.starts_with("work since: ") && p("A").after.contains("edit one | edit two"), "{}", p("A").after);
3906 assert_eq!(p("A").after, p("B").after, "delivered together, one shared evidence");
3907 for (id, word) in [("A", "applies"), ("B", "skip")] {
3908 let x = p(id);
3909 assert_eq!(x.stripped, stripped(&x.check, id));
3910 match x.stripped {
3911 true => assert!(x.declared.is_none() && !x.after.contains(&format!("{id}: {word}")), "{x:?}"),
3912 false => assert!(x.declared.is_some() && x.after.contains(&format!("{id}: {word}")), "{x:?}"),
3913 }
3914 }
3915 assert!(!p("C").stripped && !p("D").stripped, "only questions are stripped");
3916 assert!(j.question && j.crowded.is_empty());
3917 let _ = std::fs::remove_file(edits_file(&session));
3918 }
3919
3920 #[test]
3924 fn a_live_question_from_before_the_upgrade_is_still_its_lessons_latest_delivery() {
3925 let session = format!("pending-later-{}", std::process::id());
3926 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3927 let all = [c("L"), c("M")];
3928 let (a, b) = ("00000000000a", "00000000000b");
3929 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");
3930 let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
3931 let answered = format!("{asked}#borg-answer L: applies\n");
3932 for edits in [&asked, &answered] {
3933 let _ = std::fs::write(edits_file(&session), edits);
3934 for when in [When::Stop, When::End] {
3935 let j = pending(&session, &state_of(edits), &all, "", when, "00000000cafe");
3936 let got: Vec<(String, &str, String)> = j.pending.iter().map(|p| (p.lesson.id.clone(), p.channel, p.check.clone())).collect();
3937 assert_eq!(got, [("M".to_string(), "queued", a.to_string()), ("L".to_string(), "live", b.to_string())], "{when:?}: {edits}");
3938 }
3939 }
3940 let _ = std::fs::remove_file(edits_file(&session));
3941 }
3942
3943 #[test]
3946 fn a_free_delivery_or_placebo_is_judged_at_the_turn_end() {
3947 let session = format!("pending-work-{}", std::process::id());
3948 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3949 let all = [c("A"), c("C"), c("D"), c("E")];
3950 let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
3951 let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
3952 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";
3953 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")] {
3954 let _ = std::fs::write(edits_file(&session), &edits);
3955 let state = state_of(&edits);
3956 let early = edits.contains("#borg-early ");
3957 for when in [When::Stop, When::End] {
3958 let want: &[&str] = if early { &["A", "C", "D", "E"] } else { &["A", "C", "D"] };
3959 assert_eq!(ids(&pending(&session, &state, &all, "", when, "00000000cafe")), want, "{when:?}: {edits}");
3960 }
3961 }
3962 let _ = std::fs::remove_file(edits_file(&session));
3963 }
3964
3965 #[test]
3970 fn the_session_end_judge_never_outlives_its_lock() {
3971 let (end, stale) = (end_timeout(), live::stale_ms());
3972 assert!(i64::try_from(end.as_millis()).unwrap() < stale, "{end:?} against a lock stale after {stale} ms");
3973 assert!(end <= learn::learn_timeout());
3974 }
3975
3976 #[test]
3977 fn the_session_end_judge_takes_every_open_delivery_once() {
3978 let session = format!("pending-end-{}", std::process::id());
3979 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3980 let all = [c("A"), c("B"), c("C"), c("D"), c("Q")];
3981 let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
3982 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";
3983 let _ = std::fs::write(edits_file(&session), edits);
3984 let delivered: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
3985 let state = format!("{delivered}#borg-judged A=changed\n#borg-judged B=pending\n#borg-judged C=pending\n#borg-judged B=pending\n");
3987 assert_eq!(ids(&pending(&session, &state, &all, "", When::Stop, "00000000cafe")), ["C", "D", "Q"], "a check: B's tries are spent");
3988 assert_eq!(ids(&pending(&session, &state, &all, "", When::End, "00000000cafe")), ["B", "C", "D", "Q"], "the end: every open one");
3989 let ended = format!("{state}#borg-judged B=pending\n#borg-judged C=changed\n{END_JUDGED}B,C,D,Q\n");
3990 for when in [When::Stop, When::End] {
3991 assert!(pending(&session, &ended, &all, "", when, "00000000cafe").pending.is_empty(), "{when:?}: judged at the end once");
3992 }
3993 let why = unjudged(&ended);
3994 assert_eq!((why["B"], why["D"], why["Q"]), (outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES));
3995 let again = format!("{ended}#borg-queued D @0000000000c5\n");
3997 let _ = std::fs::write(edits_file(&session), format!("{edits}#borg-queued D @0000000000c5\n"));
3998 assert_eq!(ids(&pending(&session, &again, &all, "", When::End, "00000000cafe")), ["D"]);
3999 assert_eq!(unjudged(&again)["D"], outcome::UNJUDGED_NO_CALL);
4000 let _ = std::fs::remove_file(edits_file(&session));
4001 }
4002
4003 fn source(label: &'static str, text: &str, marks: &[(usize, &str)]) -> Source {
4005 Source { label, text: text.chars().collect(), marks: marks.iter().map(|(at, id)| (*at, vec![id.to_string()])).collect() }
4006 }
4007
4008 #[test]
4012 fn the_session_work_shares_its_budget_and_is_read_whole_when_it_fits() {
4013 let parts = [source("short", "abc", &[]), source("long", "0123456789", &[]), source("none", "", &[])];
4014 let none = [None, None, None];
4015 let whole = "### short\nabc\n### long\n0123456789\n";
4016 assert_eq!(within(&parts, &none, 100, Cut::After), (whole.to_string(), false));
4017 assert_eq!(within(&parts, &none, whole.chars().count(), Cut::After), (whole.to_string(), false), "exactly fits: whole");
4018 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");
4020 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");
4021 for budget in 0..60 {
4022 for cut in [Cut::Newest, Cut::After, Cut::Ends] {
4023 let (text, _) = within(&parts, &[None, Some(3), None], budget, cut);
4024 assert!(text.chars().count() <= budget, "{budget} {cut:?}: {text:?}");
4025 }
4026 }
4027 }
4028
4029 #[test]
4033 fn a_long_sessions_work_is_cut_at_the_batchs_earliest_delivery() {
4034 let text: Vec<char> = "0123456789abcdefghij".chars().collect();
4035 assert_eq!(Cut::Newest.window(&text, 6, Some(4)), "…efghij");
4036 assert_eq!(Cut::After.window(&text, 6, Some(4)), "…456789…", "from the delivery on, its start first");
4037 assert_eq!(Cut::After.window(&text, 6, Some(0)), "012345…");
4038 assert_eq!(Cut::Ends.window(&text, 6, Some(4)), "…456…hij", "half after the delivery, half the end");
4039 assert_eq!(Cut::After.window(&text, 6, Some(16)), "…efghij", "what follows fits: the newest");
4040 assert_eq!(Cut::Ends.window(&text, 6, Some(15)), "…efghij", "the halves meet: the newest");
4041 for cut in [Cut::Newest, Cut::After, Cut::Ends] {
4042 assert_eq!(cut.window(&text, 6, None), "…efghij", "{cut:?}: no delivery in it");
4043 }
4044 let parts = [source("edits", "0123456789abcdefghij", &[(4, "L")]), source("said", "ABCDEFGHIJKLMNOPQRST", &[])];
4046 let (work, cut) = within(&parts, &[parts[0].anchor(&["L"]), parts[1].anchor(&["L"])], 34, Cut::After);
4047 assert!(cut);
4048 assert_eq!(work, "### edits\n…4567…\n### said\n…PQRST\n");
4049 }
4050
4051 #[test]
4055 fn a_source_marks_where_each_delivery_stood_without_stripped_declarations() {
4056 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4057 let (a, f) = (c("A"), c("F"));
4058 let p = |lesson, stripped| predict::Pending {
4059 lesson,
4060 before: String::new(),
4061 after: String::new(),
4062 declared: None,
4063 check: String::new(),
4064 channel: "stop",
4065 stripped,
4066 };
4067 let pend = [p(&a, false), p(&f, true)];
4068 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";
4069 let s = Source::of("edits", edits, |l| l.strip_prefix("#borg-answer ").or_else(|| (!l.starts_with("#borg-")).then_some(l)), &pend);
4070 let text: String = s.text.iter().collect();
4071 assert_eq!(text, "one\nA: applies. .\ntwo\nthree");
4072 assert_eq!((s.anchor(&["F"]), s.anchor(&["A"]), s.anchor(&["A", "F"]), s.anchor(&["Z"])), (Some(4), Some(22), Some(4), None));
4073 assert!(Source::of("blank", "#borg-asked A\n \n", Some, &pend).text.is_empty(), "only whitespace: empty");
4074 }
4075
4076 #[test]
4080 fn a_pass_explores_the_step_above_on_its_logged_chance() {
4081 let d = std::env::temp_dir().join(format!("borg-end-budget-{}", std::process::id()));
4082 let _ = std::fs::remove_dir_all(&d);
4083 std::fs::create_dir_all(&d).unwrap();
4084 let draws: Vec<EndBudget> = (0..500).map(|i| end_budget(&d, "m:x", &format!("check-{i}"))).collect();
4085 let psi = crate::judge_length::EXPLORE_SHARE;
4086 for b in &draws {
4087 let explored = b.draw.expect("drawn, logged").1;
4088 assert_eq!(*b, EndBudget { chars: if explored { 108_000 } else { END_START_BUDGET }, draw: Some((psi, explored)) });
4089 }
4090 let share = draws.iter().filter(|b| b.draw.is_some_and(|d| d.1)).count() as f64 / draws.len() as f64;
4091 assert!((share - psi).abs() < 0.06, "explored {share} of passes at chance {psi}");
4092 assert_eq!(end_budget(&d, "m:x", "check-7"), draws[7], "the same seed, the same draw");
4093 let line = |chars: usize, n: u64, a: u64| {
4095 format!("judge call=1 pending={n} resolved={a} chars={chars} verdicts=changed:{a},unclear:{} model=m:x at=end\n", n - a).repeat(10)
4096 };
4097 std::fs::write(d.join("s.cost"), line(30_000, 40, 20) + &line(100_000, 40, 5)).unwrap();
4098 let floor = crate::judge_length::FAILED_EXPLORE_SHARE;
4099 let draws: Vec<EndBudget> = (0..2000).map(|i| end_budget(&d, "m:x", &format!("check-{i}"))).collect();
4100 for b in &draws {
4101 let explored = b.draw.expect("drawn, logged").1;
4102 assert_eq!(*b, EndBudget { chars: if explored { 108_000 } else { END_START_BUDGET }, draw: Some((floor, explored)) });
4103 }
4104 let share = draws.iter().filter(|b| b.draw.is_some_and(|d| d.1)).count() as f64 / draws.len() as f64;
4105 assert!((share - floor).abs() < 0.02, "explored {share} of passes at chance {floor}");
4106 let _ = std::fs::remove_dir_all(&d);
4107 }
4108
4109 #[test]
4113 fn every_lesson_lands_in_one_batch_whose_prompt_stays_within_the_budget() {
4114 let lessons: Vec<conscience::Challenge> = (0..278)
4115 .map(|i| conscience::Challenge {
4116 id: format!("L{i:03}"),
4117 question: format!("Question {i} {}", "q".repeat(i * 7 % 400)),
4118 consequence: format!("consequence {i}"),
4119 ..Default::default()
4120 })
4121 .collect();
4122 let pend: Vec<predict::Pending> = lessons
4123 .iter()
4124 .enumerate()
4125 .map(|(i, l)| predict::Pending {
4126 lesson: l,
4127 before: format!("before {} {}", i / 3, "b".repeat(i * 13 % 1300)),
4129 after: format!("after {} {}", i / 3, "a".repeat(i * 31 % 4000)),
4130 declared: None,
4131 check: String::new(),
4132 channel: "queued",
4133 stripped: false,
4134 })
4135 .collect();
4136 let long: String = (0..4000).map(|i| format!("edit {i:04} of the long session\n")).collect();
4137 let marks: Vec<(usize, &str)> = lessons.iter().enumerate().map(|(i, l)| (i * 100, l.id.as_str())).collect();
4138 let sources = [source("prompts", &"p".repeat(30_000), &[]), source("edits", &long, &marks), source("said", "", &[])];
4139 for budget in [END_START_BUDGET, 20_000, 200_000] {
4140 let groups = batches(&pend, budget);
4141 assert!(groups.len() > 1, "{budget}: {groups:?}");
4142 assert_eq!(groups.iter().flat_map(Clone::clone).collect::<Vec<_>>(), (0..pend.len()).collect::<Vec<_>>(), "each once, in order");
4143 for g in &groups {
4144 let batch = &pend[g.clone()];
4145 assert!(batch.len() == 1 || predict::judge_section(batch).chars().count() <= budget / END_LESSONS_SHARE, "{budget}: {g:?}");
4146 let fixed = predict::judge_prompt("", batch).chars().count();
4147 let ids: Vec<&str> = batch.iter().map(|p| p.lesson.id.as_str()).collect();
4148 let anchors: Vec<Option<usize>> = sources.iter().map(|s| s.anchor(&ids)).collect();
4149 let (work, cut) = within(&sources, &anchors, budget.saturating_sub(fixed), Cut::After);
4150 assert!(cut, "a long session is cut");
4151 assert!(predict::judge_prompt(&work, batch).chars().count() <= budget, "{budget}: {g:?}");
4152 let first = g.start * 100;
4154 let body = work.split("### edits\n").nth(1).unwrap().trim_end_matches('\n');
4155 let body = body.strip_prefix('…').unwrap_or(body).trim_end_matches('…');
4156 let at = long.find(body).unwrap();
4157 assert!(at == first || (at <= first && at + body.len() + 1 >= long.len()), "{budget}: {g:?} at {at}");
4158 }
4159 assert!(batches(&pend[..3], 100).iter().all(|g| g.len() == 1));
4161 }
4162 }
4163
4164 #[test]
4167 fn the_judge_cost_line_names_the_returned_and_unmatched_ids() {
4168 fn p(lesson: &conscience::Challenge) -> predict::Pending<'_> {
4169 predict::Pending { lesson, before: String::new(), after: String::new(), declared: None, check: String::new(), channel: "stop", stripped: false }
4170 }
4171 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4172 let (a, b) = (c("La"), c("Lb"));
4173 let pend = [p(&a), p(&b)];
4174 let returned = ["La".to_string(), "Lz".to_string()];
4175 let j = |id: &str, verdict, reason: &str| predict::Judged { id: id.into(), verdict, reason: reason.into() };
4176 let one = [j("La", predict::Verdict::NotArisen, "only docs"), j("Lz", predict::Verdict::Changed, "not pending")];
4177 assert_eq!(
4178 judge_line(3, &pend, &one, 900, &returned),
4179 "judge call=3 pending=2 resolved=1 chars=900 returned=La,Lz unmatched=Lz \
4180 verdicts=changed:0,ignored:0,not_arisen:1,consequence_remains:0,unclear:0 reason_chars=9"
4181 );
4182 let pair = [j("La", predict::Verdict::Unclear, ""), j("Lb", predict::Verdict::ConsequenceRemains, "still awaits")];
4183 assert_eq!(
4184 judge_line(4, &pend, &pair, 10, &[]),
4185 "judge call=4 pending=2 resolved=0 chars=10 returned= unmatched= \
4186 verdicts=changed:0,ignored:0,not_arisen:0,consequence_remains:1,unclear:1 reason_chars=12"
4187 );
4188 }
4189
4190 #[test]
4195 fn a_stripped_declaration_leaves_every_evidence_of_the_call() {
4196 let session = format!("pending-strip-{}", std::process::id());
4197 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4198 let all = [c("A"), c("F"), c("G")];
4199 let check = |lesson: &str, strip: bool| (0u64..).map(|i| format!("{i:012x}")).find(|k| stripped(k, lesson) == strip).unwrap();
4200 let (ca, cf, cg) = (check("A", false), check("F", true), check("G", false));
4201 let answer = "Fixed the arc. F: applies";
4202 let edits = format!(
4203 "Plan: the blocked message\n#borg-asked A @{ca}\n#borg-answer A: applies\nedit one\n#borg-work the work so far\n\
4204 #borg-asked F @{cf}\n#borg-live F ms=1\n#borg-answer {answer}\n{answer}\n#borg-asked G @{cg}\n"
4205 );
4206 let _ = std::fs::write(edits_file(&session), &edits);
4207 let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
4208 let j = pending(&session, &state, &all, answer, When::Stop, "00000000cafe");
4209 let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
4210 assert!(p("F").stripped && p("F").declared.is_none(), "{:?}", p("F"));
4211 for x in &j.pending {
4212 assert!(!format!("{} {}", x.before, x.after).to_lowercase().contains("f: applies"), "{x:?}");
4213 }
4214 assert!(p("F").after.contains("this turn: Fixed the arc."), "the rest of the message stays: {}", p("F").after);
4215 assert!(!p("A").stripped && p("A").declared == Some(true) && p("A").after.contains("A: applies"), "{:?}", p("A"));
4216 assert_eq!(without_declarations(&format!("work\n{answer}"), &j.pending), "work\nFixed the arc. ");
4218 assert_eq!(without_declarations("A: applies", &j.pending), "A: applies");
4219 let _ = std::fs::remove_file(edits_file(&session));
4220 }
4221
4222 #[test]
4227 fn a_merged_ask_of_two_draws_carries_no_pool() {
4228 let session = format!("pool-merge-{}", std::process::id());
4229 let _ = std::fs::remove_file(state_file(&session));
4230 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4231 let (a, b, cc, d, e, f) = (c("A"), c("B"), c("C"), c("D"), c("E"), c("F"));
4232 let live: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &b), (0.8, &cc), (0.7, &d)];
4233 append_p_of(&session, &live, &[&a, &b], "c1");
4234 let stop: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &e), (0.8, &f)];
4235 append_p_of(&session, &stop, &[&a], "c2");
4236 let state = std::fs::read_to_string(state_file(&session)).unwrap_or_default();
4237 let merged = vec!["A".to_string(), "B".to_string()];
4238 assert_eq!(propensity_in(&state, &merged).len(), 2, "each id has its latest p");
4239 assert_eq!(pool_in(&state, &merged), Default::default());
4240 assert_eq!(pool_in(&state, &["A".to_string()]).0, ["A", "E", "F"], "A alone: the stop check's pool");
4241 assert_eq!(pool_in(&state, &["B".to_string()]).0, ["A", "B", "C", "D"], "B alone: the live check's");
4242 assert_eq!(tagged_p(&state, "#borg-p ", "c1", &["B"]).len(), 1, "B's record carries its own check's p");
4243 let _ = std::fs::remove_file(state_file(&session));
4244 }
4245
4246 #[test]
4250 fn an_early_lessons_relative_score_is_its_share_of_the_best_on_topic_match() {
4251 let c = |id: &str, q: &str| conscience::Challenge { id: id.into(), question: q.into(), scope: vec!["Orion".into()], ..Default::default() };
4252 let standing = conscience::Challenge { source: conscience::CONSTRAINT.into(), ..c("S", "harpsichord tuning") };
4253 let all = vec![c("A", "orion budget place reset arc drain"), c("B", "orion budget place"), c("C", "telescope glaciers"), standing];
4254 let work = "drain the orion budget place, never with a reset arc";
4255 let topics = vec!["Orion".to_string()];
4256 let ranked = conscience::rank(&all, work);
4257 let score = |id: &str| ranked.iter().find(|(_, x)| x.id == id).map_or(0.0, |(s, _)| *s);
4258 assert!(score("A") > score("B") && score("B") > 0.0, "{ranked:?}");
4259 let rel = early_rel(&all, work, &topics, &[&all[1], &all[3]]);
4261 assert_eq!(rel, [outcome::round_p(score("B") / score("A")), 0.0]);
4262 assert!(rel[0] < 1.0);
4263 assert_eq!(early_rel(&all, work, &topics, &[&all[0]]), [1.0]);
4264 assert!(early_rel(&all, work, &["Beacon".to_string()], &[&all[3]]).is_empty());
4266 }
4267
4268 fn decide_store(name: &str) -> std::path::PathBuf {
4272 decide_store_with(name, true)
4273 }
4274
4275 fn decide_store_with(name: &str, sure: bool) -> std::path::PathBuf {
4279 use crate::select::{policy, summary};
4280 let d = std::env::temp_dir().join(format!("borg-decide-{name}-{}", std::process::id()));
4281 let _ = std::fs::remove_dir_all(&d);
4282 std::fs::create_dir_all(&d).unwrap();
4283 let lessons = d.join("lessons.jsonl");
4284 std::fs::write(&lessons, "").unwrap();
4285 std::fs::write(outcome::path_beside(&lessons), "").unwrap();
4286 let at = |name: &str| policy::NAMES.iter().position(|n| *n == name).unwrap();
4287 let mut beta = vec![0.0; policy::P];
4288 beta[at("intercept")] = -0.5;
4289 if sure {
4290 beta[at("ask")] = -3.0;
4291 beta[at("not")] = -1.0;
4292 }
4293 let mut chol = vec![0.0; policy::P * policy::P];
4294 for k in 0..policy::P {
4295 chol[k * policy::P + k] = 0.05;
4296 }
4297 let model = policy::Model { beta, chol, sd: vec![1.0; policy::P], spread: [0.1; 3], ..policy::Model::default() };
4298 let rates = policy::Rates { free_stop: 1.0, free_live: 1.0, later: 0.0, wait: 0.0, ask_live: 1.0 };
4299 let fit = policy::Fit { key: "9-abcdef01".into(), model: Some(model), rates, ..policy::Fit::default() };
4300 let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: 5.0, ..summary::Gate::default() };
4301 summary::save(&lessons, &summary::Summary { fit, gate, ..summary::Summary::default() }).unwrap();
4302 lessons
4303 }
4304
4305 fn record_beside(lessons: &std::path::Path, applied: &[(&conscience::Challenge, u8)], check: &str, r: &crate::select::Routed, basis: &Basis) -> bool {
4308 let order: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
4309 let call = Call {
4310 eligible: order.len(),
4311 order: &order,
4312 tail: &[],
4313 pt: 0.0,
4314 boundary: &[],
4315 budget: 0,
4316 model: "",
4317 prompt_hash: String::new(),
4318 dur_ms: 1,
4319 usd: 0.0,
4320 line: "",
4321 moves: Vec::new(),
4322 whys: Vec::new(),
4323 };
4324 let o = graded_outcome("decide-s", "r", check, applied, Some((r, basis)), basis.summary, &call);
4325 outcome::append(&outcome::path_beside(lessons), &o).is_ok()
4326 }
4327
4328 #[test]
4331 fn the_graded_record_carries_the_question_count_tests_draw() {
4332 use crate::select::{Log, Routed, policy, summary};
4333 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4334 let ls = [c("A"), c("B")];
4335 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([2, 1]).collect();
4336 let order: Vec<&conscience::Challenge> = ls.iter().collect();
4337 let call = Call {
4338 eligible: 2,
4339 order: &order,
4340 tail: &[],
4341 pt: 0.0,
4342 boundary: &[],
4343 budget: 16_000,
4344 model: "",
4345 prompt_hash: String::new(),
4346 dur_ms: 1,
4347 usd: 0.0,
4348 line: "",
4349 moves: Vec::new(),
4350 whys: Vec::new(),
4351 };
4352 let s = summary::Summary::default();
4353 let basis = Basis { summary: &s, had: &[], why: "" };
4354 let routed = |source: &'static str, xk: Option<u32>, xs: Option<u32>| Routed {
4355 ask: Vec::new(),
4356 free: vec![&ls[0], &ls[1]],
4357 placebo: Vec::new(),
4358 p: vec![0.0, 0.0],
4359 pf: vec![1.0, 1.0],
4360 log: Log { policy: "learned", source, xk, xs, ..Log::default() },
4361 };
4362 let record = |r: Option<&Routed>| graded_outcome("s", "r", "0123456789ab", &applied, r.map(|r| (r, &basis)), &s, &call);
4363 let drawn = record(Some(&routed(policy::EXPERIMENT_CONTROL, Some(2), Some(0))));
4364 assert_eq!((drawn.source.as_str(), drawn.xk, drawn.xs), (policy::EXPERIMENT_CONTROL, Some(2), Some(0)));
4365 let outside = record(Some(&routed(policy::EVIDENCE, None, None)));
4366 assert_eq!((outside.source.as_str(), outside.xk, outside.xs), (policy::EVIDENCE, None, None));
4367 let failed = record(None);
4368 assert_eq!((failed.failed, failed.xk, failed.xs), (true, None, None));
4369 assert_eq!((drawn.budget, failed.budget), (16_000, 16_000));
4371 }
4372
4373 fn graded_records(lessons: &std::path::Path) -> Vec<outcome::Outcome> {
4374 outcome::read(&outcome::path_beside(lessons)).into_iter().filter(|o| o.kind == outcome::GRADED).collect()
4375 }
4376
4377 #[test]
4380 fn a_check_decides_on_the_summary_under_the_lock_with_every_decision_before_it() {
4381 use crate::select::{Policy, policy, summary};
4382 let lessons = decide_store("fresh");
4383 let stale = summary::refresh(&lessons);
4385 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4386 let ls = [c("A"), c("B"), c("C"), c("D")];
4387 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
4388 let mut gates = Vec::new();
4389 for check in ["0000000000a1", "0000000000b2"] {
4392 let at = Decide { session: "decide-fresh", check, channel: "live", lessons: &lessons, tokens: 0, recorded: true };
4393 let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |r, b| record_beside(&lessons, &applied, check, r, b));
4394 gates.push(r.log.gate);
4395 }
4396 let g = graded_records(&lessons);
4397 assert_eq!(g.len(), 2);
4398 assert!(g.iter().all(|o| o.policy == "learned" && o.reason.is_empty()), "{g:?}");
4399 assert_ne!(g[0].free, Vec::<String>::new(), "the first deviates: everything free at a live check, {:?}", g[0]);
4400 assert_eq!(g[0].seen, stale.key(), "nothing was decided before the first");
4401 let log = std::fs::read_to_string(outcome::path_beside(&lessons)).unwrap();
4403 let first = log.lines().next().unwrap();
4404 let after = summary::fold(&format!("{first}\n"), stale.clone());
4405 assert_eq!(after.gate.checks, stale.gate.checks + 1, "the first's decision is in the gate's ledger");
4406 assert_eq!(g[1].seen, after.key(), "decided on the summary loaded under the lock, not the one read at its start");
4407 assert_eq!(gates[1], policy::remaining(&after.gate).max(0.0), "against the gate with the first's entry");
4408 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4409 }
4410
4411 #[test]
4417 fn two_checks_deciding_at_once_never_both_take_the_gate() {
4418 use crate::select::{Policy, policy, summary};
4419 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4420 let ls = [c("A"), c("B"), c("C"), c("D")];
4421 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
4422 let mut stores = Vec::new();
4423 for (name, sure, sources) in [("race", true, &[policy::EVIDENCE][..]), ("race-test", false, &[policy::EXPERIMENT, policy::EXPERIMENT_CONTROL][..])] {
4424 let lessons = decide_store_with(name, sure);
4425 let stale = summary::refresh(&lessons);
4426 let both = std::sync::Barrier::new(2);
4429 std::thread::scope(|s| {
4430 for check in ["0000000000c1", "0000000000c2"] {
4431 let (applied, lessons, stale, both) = (&applied, &lessons, &stale, &both);
4432 s.spawn(move || {
4433 let at = Decide { session: "decide-race", check, channel: "live", lessons, tokens: 0, recorded: true };
4434 decide(Policy::Learned, applied, &at, stale, std::time::Duration::ZERO, |r, b| {
4435 both.wait();
4436 record_beside(lessons, applied, check, r, b)
4437 });
4438 });
4439 }
4440 });
4441 let mut got: Vec<(String, String, String, bool)> =
4442 graded_records(&lessons).into_iter().map(|o| (o.policy, o.source, o.reason, o.xk.is_some())).collect();
4443 got.sort();
4444 assert_eq!(got.len(), 2, "{name}: {got:?}");
4445 assert_eq!(got[0], ("fixed".to_string(), "fixed".to_string(), outcome::DECIDED_BUSY.to_string(), false), "{name}: the other took the fixed rule");
4446 let (policy, source, reason, drawn) = &got[1];
4447 assert!(policy == "learned" && sources.contains(&source.as_str()) && reason.is_empty(), "{name}: one decided under the lock: {got:?}");
4448 assert_eq!(*drawn, !sure, "{name}: the test's draw on its record alone");
4449 assert!(policy::remaining(&summary::refresh(&lessons).gate) >= -1e-9, "{name}: the gate never overspent");
4450 stores.push(lessons);
4451 }
4452 let stale = summary::refresh(&stores[0]);
4454 let gone = std::path::Path::new("/nonexistent/borg-decide/lessons.jsonl");
4455 let at = Decide { session: "decide-race", check: "0000000000c3", channel: "live", lessons: gone, tokens: 0, recorded: true };
4456 let mut why = "";
4457 let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |_, b| {
4458 why = b.why;
4459 true
4460 });
4461 assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_NO_LOCK));
4462 for lessons in stores {
4463 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4464 }
4465 }
4466
4467 #[test]
4471 fn a_policy_that_reads_no_gate_decides_by_itself_whoever_holds_the_lock() {
4472 use crate::select::{Ctx, Policy, graded_under, summary};
4473 let lessons = decide_store("nolock");
4474 let stale = summary::refresh(&lessons);
4475 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4476 let ls = [c("A"), c("B"), c("C"), c("D")];
4477 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 3, 2]).collect();
4478 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
4479 let held = lock_within(&decide_lock(&lessons), std::time::Duration::ZERO).unwrap();
4480 let check = "0000000000e1";
4481 let at = Decide { session: "decide-nolock", check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
4482 let ctx = Ctx {
4483 session: at.session,
4484 check,
4485 channel: at.channel,
4486 lessons: &lessons,
4487 summary: &stale,
4488 tokens: 0,
4489 had: &[],
4490 alpha: crate::select::alpha(&lessons),
4491 };
4492 let fixed = ids(&graded_under(Policy::Fixed, &applied, &ctx).ask);
4493 for policy in [Policy::Fixed, Policy::Cap(1), Policy::Attention] {
4494 let own = graded_under(policy, &applied, &ctx);
4495 let mut got = None;
4496 let r = decide(policy, &applied, &at, &stale, std::time::Duration::ZERO, |r, b| {
4497 got = Some((r.log.policy, b.why, b.summary.key()));
4498 true
4499 });
4500 assert_eq!(got, Some((policy.name(), "", stale.key())), "{policy:?}");
4501 assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&own.ask), ids(&own.free)), "{policy:?}: its own rule");
4502 }
4503 assert_ne!(ids(&graded_under(Policy::Cap(1), &applied, &ctx).ask), fixed);
4505 let mut why = "";
4506 let r = decide(Policy::Learned, &applied, &at, &stale, std::time::Duration::ZERO, |_, b| {
4507 why = b.why;
4508 true
4509 });
4510 assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_BUSY), "the learned policy alone waits for the lock");
4511 drop(held);
4512 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4513 }
4514
4515 #[test]
4520 fn a_learned_decision_whose_record_cannot_land_takes_the_fixed_rule() {
4521 use crate::select::{Ctx, Policy, graded_under, policy, summary};
4522 let lessons = decide_store("unrecorded");
4523 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4524 let ls = [c("A"), c("B"), c("C"), c("D")];
4525 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
4526 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
4527 let at = |check: &'static str, recorded: bool| Decide { session: "decide-unrecorded", check, channel: "live", lessons: &lessons, tokens: 0, recorded };
4528 let fixed = |check: &str, s: &summary::Summary| {
4529 let ctx = Ctx {
4530 session: "decide-unrecorded",
4531 check,
4532 channel: "live",
4533 lessons: &lessons,
4534 summary: s,
4535 tokens: 0,
4536 had: &[],
4537 alpha: crate::select::alpha(&lessons),
4538 };
4539 let r = graded_under(Policy::Fixed, &applied, &ctx);
4540 (ids(&r.ask), ids(&r.free), ids(&r.placebo))
4541 };
4542 let stale = summary::refresh(&lessons);
4544 let r = decide(Policy::Learned, &applied, &at("0000000000f1", true), &stale, std::time::Duration::ZERO, |r, b| {
4545 record_beside(&lessons, &applied, "0000000000f1", r, b)
4546 });
4547 assert_eq!(r.log.source, policy::EVIDENCE, "{:?}", r.log);
4548 let (ask, free, _) = fixed("0000000000f1", &stale);
4549 assert_ne!((ids(&r.ask), ids(&r.free)), (ask, free), "it deviated");
4550 let gate = summary::refresh(&lessons).gate;
4551 for check in ["0000000000f2", "0000000000f3"] {
4553 let stale = summary::refresh(&lessons);
4554 let mut tried = Vec::new();
4555 let r = decide(Policy::Learned, &applied, &at(check, false), &stale, std::time::Duration::ZERO, |r, b| {
4556 tried.push((r.log.policy, b.why));
4557 false
4558 });
4559 assert_eq!(tried, [("fixed", outcome::DECIDED_NO_RECORD)], "{check}");
4560 assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed(check, &stale), "{check}");
4561 }
4562 let stale = summary::refresh(&lessons);
4565 let mut tried = Vec::new();
4566 let r = decide(Policy::Learned, &applied, &at("0000000000f4", true), &stale, DECIDE_WAIT, |r, b| {
4568 tried.push((r.log.policy, b.why));
4569 false
4570 });
4571 assert_eq!(tried, [("learned", ""), ("fixed", outcome::DECIDED_NO_RECORD)]);
4572 assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed("0000000000f4", &stale));
4573 assert_eq!(graded_records(&lessons).len(), 1, "only the first check's record");
4574 assert_eq!(summary::refresh(&lessons).gate, gate, "no unrecorded decision moved the gate");
4575 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4576 }
4577
4578 #[test]
4583 fn a_lesson_waiting_in_the_queue_never_takes_a_free_or_placebo_slot() {
4584 use crate::select::{Policy, summary};
4585 let lessons = decide_store("queued");
4586 let stale = summary::refresh(&lessons);
4587 let session = format!("decide-queued-{}", std::process::id());
4588 let _ = std::fs::remove_file(state_file(&session));
4589 std::fs::write(queued_file(&session), "X @0000000000d0\nlive Y @0000000000d0\nplacebo Z @0000000000d0\n").unwrap();
4590 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4591 let named: Vec<String> = (1..=10).map(|i| format!("L{i:02}")).collect();
4592 let ls: Vec<conscience::Challenge> = ["X", "Y", "Z"].iter().map(|i| c(i)).chain(named.iter().map(|i| c(i))).collect();
4593 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
4594 let first: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if ["X", "Y"].contains(&c.id.as_str()) { 2 } else { 1 })).collect();
4597 let last: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if c.id.starts_with('L') { 2 } else { 1 })).collect();
4598 let waits = |l: &[String]| l.iter().any(|i| i == "X" || i == "Y");
4599 for (check, applied) in [("0000000000d1", &first), ("0000000000d2", &last)] {
4600 let at = Decide { session: &session, check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
4601 let r = decide(Policy::Fixed, applied, &at, &stale, std::time::Duration::ZERO, |r, b| record_beside(&lessons, applied, check, r, b));
4602 let (free, placebo) = (ids(&r.free), ids(&r.placebo));
4603 assert_eq!(free.len(), conscience::CAP, "{check}: every free slot taken: {free:?}");
4604 assert!(!waits(&free) && !waits(&placebo), "{check}: free {free:?}, placebo {placebo:?}");
4605 }
4606 assert_eq!(graded_records(&lessons)[1].placebo, ["Z"], "waiting as a placebo, Z is still drawn");
4607 assert!(graded_records(&lessons)[0].free.contains(&"Z".to_string()), "and still goes free");
4608 let g = graded_records(&lessons);
4609 assert_eq!(g.iter().map(|o| o.nofree.clone()).collect::<Vec<_>>(), [["X", "Y"], ["X", "Y"]], "{g:?}");
4610 let _ = std::fs::remove_file(queued_file(&session));
4611 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4612 }
4613
4614 #[test]
4615 fn only_an_adapter_reads_which_messages_an_answer_showed() {
4616 let out = json!({"hookSpecificOutput": {"additionalContext": "[borg] Message from s1: hi"}, "shown_ids": ["m0123456789ab"]});
4617 for name in ["claude", "codex"] {
4618 let h = harness::by_name(name).unwrap();
4619 assert_eq!(for_harness(h, out.clone()), json!({"hookSpecificOutput": out["hookSpecificOutput"]}), "{name}");
4620 }
4621 for name in ["omp", "opencode"] {
4622 assert_eq!(for_harness(harness::by_name(name).unwrap(), out.clone()), out, "{name}");
4623 }
4624 }
4625}
4626
4627#[cfg(test)]
4628mod acted_since_tests {
4629 use super::*;
4630
4631 fn acted_scan(state: &str, edits: &str, all: &[conscience::Challenge], final_msg: &str) -> std::collections::HashSet<String> {
4634 let lines: Vec<&str> = edits.lines().filter(|l| !l.starts_with(WORK)).collect();
4635 let mut out = std::collections::HashSet::new();
4636 for (p, l) in lines.iter().enumerate() {
4637 let Some(list) = l.strip_prefix("#borg-early ").or_else(|| l.strip_prefix("#borg-queued ")) else { continue };
4638 let ids: std::collections::HashSet<&str> = ids_in(list).collect();
4639 let asked: Vec<&conscience::Challenge> = all.iter().filter(|c| ids.contains(c.id.as_str())).collect();
4640 let after: Vec<&str> = lines[p + 1..].iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
4641 let upto = state.find(&format!("{l}\n")).map_or(state, |i| &state[..i]);
4642 let before = format!("{upto}\n{}", lines[..p].join("\n"));
4643 out.extend(crate::memory::resolve(&asked, all, &before, &after.join("\n"), final_msg, crate::memory::NOVEL_WORDS).0);
4644 }
4645 out
4646 }
4647
4648 fn pick<'a>(r: &mut crate::memory::Rng, xs: &[&'a str]) -> &'a str {
4649 xs[(r.next_u64() % xs.len() as u64) as usize]
4650 }
4651
4652 #[test]
4657 fn the_index_equals_the_scan_on_random_sessions() {
4658 let vocab = [
4659 "budget",
4660 "place",
4661 "drain",
4662 "resetArc",
4663 "token",
4664 "transition",
4665 "inhibitor",
4666 "arc",
4667 "netBudget",
4668 "ÜberPlace",
4669 "straße",
4670 "ΣIGMA",
4671 "fork_join",
4672 "subnet",
4673 "deadlock",
4674 "x",
4675 "go",
4676 "HTTPServer",
4677 "parseJSONLine",
4678 "retry",
4679 "bound",
4680 "guard",
4681 "Place",
4682 "PLACE",
4683 "café",
4684 "L1",
4685 "early",
4686 ];
4687 let mut acted = 0;
4688 for seed in 0..3000u64 {
4689 let mut r = crate::memory::Rng::new(seed);
4690 let nl = 3 + (r.next_u64() % 8) as usize;
4691 let all: Vec<conscience::Challenge> = (0..nl)
4692 .map(|k| conscience::Challenge {
4693 id: format!("L{k}"),
4694 question: (0..(4 + r.next_u64() % 10)).map(|_| pick(&mut r, &vocab)).collect::<Vec<_>>().join(" "),
4695 ..Default::default()
4696 })
4697 .collect();
4698 let text = |r: &mut crate::memory::Rng| {
4699 let sep = if r.next_u64().is_multiple_of(2) { " " } else { "-" };
4700 (0..(r.next_u64() % 9)).map(|_| pick(r, &vocab)).collect::<Vec<_>>().join(sep)
4701 };
4702 let (mut edits, mut state) = (String::new(), String::new());
4703 for _ in 0..(r.next_u64() % 30) {
4704 let line = match r.next_u64() % 9 {
4705 0 | 1 => {
4706 let ids: Vec<String> = (0..1 + r.next_u64() % 3).map(|_| format!("L{}", r.next_u64() % (nl as u64 + 1))).collect();
4707 let marker = if r.next_u64().is_multiple_of(2) { "#borg-early " } else { "#borg-queued " };
4708 let check = if r.next_u64().is_multiple_of(2) { format!(" @c{}", r.next_u64() % 3) } else { String::new() };
4709 format!("{marker}{}{check}", ids.join(","))
4710 }
4711 2 => format!("{WORK}{}", text(&mut r)),
4712 3 => format!("#borg-answer {}", text(&mut r)),
4713 _ => text(&mut r),
4714 };
4715 match r.next_u64() % 6 {
4716 0 => {}
4717 1 => state.push_str(&format!("{} {line}\n", text(&mut r))),
4718 2 => state.push_str(&format!("{line}\n{}\n{line}\n", text(&mut r))),
4719 _ => state.push_str(&format!("{line}\n")),
4720 }
4721 state.push_str(&text(&mut r));
4722 state.push('\n');
4723 edits.push_str(&line);
4724 edits.push('\n');
4725 }
4726 let mut fin = text(&mut r);
4727 if r.next_u64().is_multiple_of(3) {
4728 let verdict = if r.next_u64().is_multiple_of(2) { "applies" } else { "skip" };
4729 fin.push_str(&format!("\nL{}: {verdict}", r.next_u64() % nl as u64));
4730 }
4731 let want = acted_scan(&state, &edits, &all, &fin);
4732 assert_eq!(acted_since(&state, &edits, &all, &fin), want, "seed {seed}\nstate:\n{state}\nedits:\n{edits}\nfinal: {fin}");
4733 let cut = (r.next_u64() as usize) % (edits.len() + 1);
4734 if edits.is_char_boundary(cut) {
4735 assert_eq!(acted_since(&state, &edits[..cut], &all, ""), acted_scan(&state, &edits[..cut], &all, ""), "seed {seed} cut {cut}");
4736 }
4737 acted += usize::from(!want.is_empty());
4738 }
4739 assert!(acted > 300, "only {acted} sessions with a lesson acted on");
4740 }
4741
4742 #[test]
4746 #[ignore]
4747 fn the_index_equals_the_scan_on_a_real_session() {
4748 let (Some(dir), Some(s), Some(lessons)) =
4749 (std::env::var_os("BORG_ACTED_STATE"), std::env::var("BORG_ACTED_SESSION").ok(), std::env::var_os("BORG_ACTED_LESSONS"))
4750 else {
4751 panic!("set BORG_ACTED_STATE, BORG_ACTED_SESSION and BORG_ACTED_LESSONS");
4752 };
4753 let state = std::fs::read_to_string(state_file_in(Path::new(&dir), &s)).unwrap();
4754 let edits = std::fs::read_to_string(state_file_in(Path::new(&dir), &s).with_extension("edits")).unwrap();
4755 let all = conscience::load_store_for(Path::new(&lessons), None, None);
4756 for fin in ["", "I replaced the drain of the budget place with a reset arc.", "Done: the hook folds the outcome history incrementally."] {
4757 let t = std::time::Instant::now();
4758 let want = acted_scan(&state, &edits, &all, fin);
4759 let scan = t.elapsed();
4760 let t = std::time::Instant::now();
4761 assert_eq!(acted_since(&state, &edits, &all, fin), want);
4762 eprintln!("scan {scan:?}, index {:?}: {} acted", t.elapsed(), want.len());
4763 }
4764 for cut in (0..edits.len()).step_by(edits.len() / 40 + 1).filter(|c| edits.is_char_boundary(*c)) {
4765 assert_eq!(acted_since(&state, &edits[..cut], &all, ""), acted_scan(&state, &edits[..cut], &all, ""), "cut {cut}");
4766 }
4767 }
4768}