Skip to main content

borg/
hook.rs

1//! The hook: every harness's events, one canonical, Claude-shaped event each (session_id, cwd, prompt,
2//! tool_name, tool_input, error, last_assistant_message, transcript_path, stop_hook_active), answered
3//! Claude-shaped ({"decision":"block","reason"} at stop, `hookSpecificOutput` with context at a prompt
4//! or tool call); adapters translate both ways (harness/). What differs per harness comes from its
5//! [`Harness`]: capabilities, the turn's work and the drone's last message, never its name.
6//!
7//! `main` routes and records presence; [`dispatch`] has one function per event. Hooks always fail open:
8//! any error lets the drone continue untouched.
9
10use 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
26/// `session`'s state file in the state directory `dir` (`state_file`, where the hooks' is).
27pub(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
32/// One line (or several, kept together) at the end of the state file, in one write: a check running in
33/// the background appends to the same file, and its lines never land between these.
34pub(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
40/// What a tool call touched, as text for matching: paths and search patterns.
41pub(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            // An edit of several files (a Codex patch) names the further ones in its `edits`.
54            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
65/// A shell command's text kept as its touch when it names no path (`touched`). Guard: bounds one
66/// state-file line (a heredoc would otherwise go in whole). borg-trace's `TOUCH_CHARS`, which a replay
67/// of this hook mirrors.
68const COMMAND_CHARS: usize = borg_trace::dialogue::TOUCH_CHARS;
69
70/// The files an edit names in its `edits` beside its own `file_path` (a Codex patch of several files;
71/// Claude's MultiEdit names none there). They reach `.ctx` and `.edits`; presence (`borg top`) shows
72/// one file per drone, the edit's own `file_path`.
73pub(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
81/// Counts one event of this session in the collective's statistics (stats.rs); `fill` adds details.
82fn 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    // The engineer as presence.rs already resolved it at this hook: no second `git config` call.
88    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
93/// The engineer's prompts this session (the queen's criterion 3; the state file mixes in touches).
94fn prompts_file(session: &str) -> PathBuf {
95    state_file(session).with_extension("prompts")
96}
97
98/// Where the session works: the repo it runs in and the paths it touched (`where_now` adds the latest
99/// prompt). Scopes are judged here only, so a system merely mentioned in a command's text or in an
100/// earlier part of the conversation is not a topic.
101fn where_file(session: &str) -> PathBuf {
102    state_file(session).with_extension("where")
103}
104
105/// Where the session works now: the repo and touched paths so far, plus the engineer's latest prompt
106/// (an earlier prompt's system is not this turn's work).
107fn 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
126/// A tool call refused without words, waiting for the engineer's next prompt (their reason).
127/// Pending only while the drone waits: a later successful tool call or the session's end clears it,
128/// because omp lets the model carry on after a denial and the next prompt may be about anything.
129fn denied_file(session: &str) -> PathBuf {
130    state_file(session).with_extension("denied")
131}
132
133/// A correcting prompt becomes an episode (task, the drone's last proposal, the correction);
134/// so does the first prompt after a wordless tool denial. Any other prompt becomes the session's task.
135fn 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
156/// A veto said in the session (veto.rs): its episode, the shown lessons' credit and its stat. A pending
157/// wordless denial is answered by it, so it never becomes a correction later.
158fn 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
177/// A rejected tool call or plan that carries the engineer's words becomes an episode; a wordless
178/// denial waits for the next prompt (`capture_prompt`).
179fn 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
199/// The drone's last proposal, its last `learn::PROPOSAL_CHARS` characters (`harness::last_message`: the
200/// adapter's, else the transcript's).
201fn 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
206/// The drone's whole last proposal (`proposal` keeps its end): the reasoning note
207/// redacts it before it cuts (notes.rs).
208fn 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
231/// The lessons the conscience reads (BORG_CONSCIENCE, the BORG_BEFORE cut-off) as `session` sees them:
232/// weighted by their memory with this session's usefulness sample (memory.rs), without the ones it may
233/// not deliver here (systems, reach, absorbed). Every hook path that hands lessons to a drone reads them
234/// here.
235fn lessons(session: &str) -> Vec<conscience::Challenge> {
236    loaded(session).0
237}
238
239/// Every lesson the judge gives a verdict on: `all` and the absorbed ones withheld from it (`loaded`).
240/// A delivery made before its lesson was absorbed is evidence about the lesson, not a delivery, so the
241/// judge still sees it (`Work::judged`).
242fn 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
246/// `lessons`, and the lessons the absorbed filter withheld from it (absorb.rs `withhold`).
247fn 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    // The routed collective's lessons and this engineer's personal ones of it (reach.rs), nothing else.
250    let mut all = conscience::load_store_for(&lessons_path(), before, Some(session));
251    // The system gate (systems.rs): where the repo says which system this is, lessons scoped to other
252    // systems score 0, however much the conversation names them.
253    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    // Reach (reach.rs): a tombstone is never delivered, a repo convention only in its repo.
259    all.retain(|c| crate::reach::deliverable(c, &repos));
260    // Absorbed (absorb.rs): held outside Borg now, so delivered on no path; every path reads this list.
261    let withheld = crate::absorb::withhold_beside(&lessons_path(), &mut all, before);
262    // Spike (concepts.rs): lessons sharing engineering concepts with the code written, the touched paths
263    // and the latest prompt rank higher; never a filter.
264    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
273/// The lessons file the conscience reads: BORG_CONSCIENCE, default $BORG_HOME/lessons.jsonl.
274fn lessons_path() -> PathBuf {
275    std::env::var_os("BORG_CONSCIENCE").map(PathBuf::from).unwrap_or_else(|| learn::home().join("lessons.jsonl"))
276}
277
278/// What the drone did, for R1 (`pending`, `early_acted`): one line per Edit/Write/MultiEdit/NotebookEdit
279/// (path and the start of the new text); at each Stop block the blocked message and `#borg-asked <ids>`;
280/// the drone's answer at the turn end after it (`#borg-answer <message>`); `#borg-early <ids>` per early
281/// delivery. Kept out of the state file so the drone's code never feeds the conscience's matching.
282fn edits_file(session: &str) -> PathBuf {
283    state_file(session).with_extension("edits")
284}
285
286/// What Borg cost this session (R3b), one line per delivery: `block questions=N chars=M` for a Stop
287/// block (an extra round), `free event=E lessons=N chars=M` for context on a hook that forces no round.
288/// `borg status` sums them (prefilter.rs `tally`).
289fn cost_file(session: &str) -> PathBuf {
290    state_file(session).with_extension("cost")
291}
292
293/// BORG_STOP_GATE=edits: one line per tool call this turn that changed state (prefilter.rs `changes`);
294/// the Stop removes it.
295fn 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
305/// Lessons delivered early (`#borg-early` markers in the .edits file, and graded lessons handed over free,
306/// `#borg-queued`) that the drone acted on since: its work after the delivery brings in the lesson's words
307/// (memory.rs `resolve`, `final_msg` included). Stop leaves them alone; an early lesson not acted on can
308/// still be asked at Stop. A later delivery's own work line (`WORK`: a free delivery's, a placebo's; a live
309/// ask's before 0.7.6) is neither: it is the work before that delivery, written whatever its route, so how
310/// a later lesson was routed never decides whether this one was acted on.
311fn 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
317/// `early_acted` of a session's state file and .edits lines, in one pass over each: O(session + deliveries
318/// × their lessons' question words), where reading each delivery's before and after whole is O(deliveries ×
319/// session), seconds in a long session.
320///
321/// Per delivery `resolve` reads the text before it (the state file up to the delivery's line, at `cut`,
322/// where `{line}\n` first appears, and every .edits line before it) and the work after it (the later
323/// .edits lines that are no `#borg-` bookkeeping, and the final message). Tokens never span a line break,
324/// so for the delivery at .edits line `p` a lesson's distinctive question word is new exactly when it
325/// occurs in a later work line or the final message, its first word in the state file starts at or after
326/// `cut`, and its first .edits line is not before `p`. The index keeps those four facts per question word
327/// of the lessons delivered, nothing per other word.
328fn 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    // Each delivery's lessons (indices into `all`), each lesson's question words as slots of the index.
342    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    // Per slot: its first word's byte in the state file, its first .edits line, its last work line, and
364    // whether the final message has it.
365    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    // Where each delivery line first appears in the state file followed by a line end (`str::find` of
388    // `{line}\n`). Every such place starts with a marker, which cannot overlap itself, so one pass over
389    // the markers' places finds them all.
390    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
417/// The .edits file up to its length `upto` (a detached check's stop, check_async.rs `Job::edits`): the
418/// turn it judges and the deliveries before it, never the next turn's lines; all of it for None.
419fn 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
427/// Done in this session: asked as a question (at a stop, or live before 0.7.6), or handed over (early,
428/// free) and acted on since (`early_acted`, its `acted`). The one definition a stop's pool leaves out: a
429/// lesson handed over but not acted on may be graded again, and when its grade asks it the question
430/// reaches the drone.
431fn 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
437/// The lessons an earlier graded check of the session applied (its `#borg-graded` lines, `graded_line`):
438/// in play for the pool log (pools.rs `IN_PLAY`).
439fn 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
448/// Asked or handed over in this session, acted on or not: free context comes once per session, so
449/// none of these goes free again (`queued`, `early`), done or not.
450fn 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
457/// The .edits line before a free delivery's or a placebo's markers (a live ask's before 0.7.6): the work so far
458/// (`work_so_far`), the evidence `pending` reads before it as a question's blocked message. Not work
459/// itself: `early_acted` and a judge's "work since" skip it, as every `#borg-` line, so an earlier
460/// delivery's evidence never depends on how a later lesson was routed (`note_work`).
461const WORK: &str = "#borg-work ";
462
463/// An edit tool's paths (every file it names, so the cut never drops one) and the start of its new
464/// text, on one line; None for other tools.
465pub(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
486/// An edit's line in the `.edits` and turn files (`edit_text`): its paths and the start of its new text.
487/// Guard: bounds one evidence line (a whole written file would otherwise go in).
488const EDIT_CHARS: usize = 600;
489
490/// The longest drone message one evidence line of the `.edits` file keeps: the blocked message before
491/// a question, the work so far before a free delivery, the answer after a block (`pending`).
492/// Guard: bounds a line of a file every judge call reads; at least what the judge reads after a
493/// delivery (`predict::JUDGE_PART` three times), so the cut never takes evidence the judge would see.
494/// Not a context budget: none of it is injected into the drone.
495const EVIDENCE_CHARS: usize = 4000;
496const _: () = assert!(EVIDENCE_CHARS >= 3 * predict::JUDGE_PART);
497
498/// `text` on one line, at most `n` characters.
499pub(crate) fn one_line(text: &str, n: usize) -> String {
500    text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(n).collect()
501}
502
503/// R1: the first turn end after a Stop block is the drone's answer to it. Its message (with any
504/// `<id>: applies|skip`) goes to the .edits file as `#borg-answer`: evidence for the judge (`pending`),
505/// never a label by itself.
506fn 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
518/// Pending lessons sent to a check's judge per call at most (the most recently delivered). Guard: bounds
519/// one check's judge section, whatever a session delivered, so the stop's latency stays bounded; derived
520/// from one full block (conscience.rs `CAP`), whose lessons share their evidence in the prompt. A lesson
521/// it leaves out is logged (`#borg-unjudged <ids> why=slots`), so its hit rate is measured, and is judged
522/// at the session's end at the latest, which has no slots (`judge_session`).
523const MAX_PENDING: usize = conscience::CAP;
524/// A lesson whose verdicts stay pending (unclear, missing) is sent to a check's judge this often at
525/// most; after that it waits for the session-end judge (`judge_session`), which judges it once more
526/// whatever its tries, then for `borg learn` to settle it as `asked_not_corrected` (censored, reason
527/// `tries`). Guard: a lesson the judge never settles would otherwise ride on every later call. A failed
528/// call is no try: its lessons stay pending as they were (`#borg-unjudged … why=failed`).
529const MAX_JUDGE_TRIES: usize = 2;
530
531/// The session-end judge's prompt budget (`end_budget`) before any evidence of the judge's effective
532/// length: characters of a batch's whole prompt (`predict::judge_prompt`). Baseline (0.7.6
533/// pre-registration, 2026-11-01): 0.7.5's 24,000 characters of work and its 40 lessons at
534/// `predict::JUDGE_PART` (1,200) each. Not the largest prompt 0.7.5's one session-end call could send:
535/// with every lesson's full evidence (1,200 before, 3,600 after, its question and consequence) that was
536/// about 220,000; the pre-registered value is kept, well below it, so batching never asks more of the
537/// judge than 0.7.5 could before the measured effective length says it may.
538pub(crate) const END_START_BUDGET: usize = 72_000;
539
540/// A session-end batch's lessons (`predict::judge_section`) take at most 1/this of its budget; the
541/// session's work gets the rest (`batches`). Guard: the work, every lesson's evidence, is never crowded
542/// out by the lessons beside it.
543const END_LESSONS_SHARE: usize = 2;
544
545/// The session-end judge's budget for one pass (`end_budget`).
546#[derive(Debug, Clone, Copy, PartialEq)]
547pub(crate) struct EndBudget {
548    /// Characters a batch's whole prompt may take (`predict::judge_prompt`).
549    pub chars: usize,
550    /// When the pass drew whether to explore a larger budget: the chance it explored and whether it
551    /// did (the cost line's `psi=` and `explore=`).
552    pub draw: Option<(f64, bool)>,
553}
554
555/// The session-end judge's prompt budget for this pass, for the judge `model` (`judge_model`): its
556/// measured effective length from the store's judge cost lines (judge_length.rs `length`, the `.cost`
557/// files of `state`), or, with the chance `psi` the estimate gives while the step above it is open, that
558/// step, drawn from `seed` and logged (`draw`). `BORG_END_BUDGET=<chars>` pins it (an evaluation switch:
559/// no estimate, no draw).
560pub(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
576/// The judge's model as cost lines name it (`model=`): the first runner `spec` picks and the model it
577/// asks (runner.rs `Choice::label`); empty when none.
578pub(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/// How a session-end batch cuts a part of the session's work longer than its share of the budget
583/// (`BORG_END_WORK`): its newest characters (`newest`, 0.7.5's), from the batch's earliest delivery on
584/// (`after`), or half right after that delivery and half the end (`ends`). A part without delivery
585/// markers (the agent's messages) keeps its newest characters under each. Which one reads more verdicts
586/// out of a long session was the 0.7.6 backtest's question (docs/evidence.md, Lesson selection, 0.7.6
587/// pre-registration): `ends` answered the most on lessons delivered long before the end (196 of 196,
588/// `newest` 195, `after` 192), so it is the default.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590enum Cut {
591    Newest,
592    After,
593    Ends,
594}
595
596impl Cut {
597    /// `BORG_END_WORK`, else `ends`; an unknown word is the default too.
598    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    /// Its word on the cost line (`cut=`).
607    fn word(self) -> &'static str {
608        match self {
609            Cut::Newest => "newest",
610            Cut::After => "after",
611            Cut::Ends => "ends",
612        }
613    }
614
615    /// `n` of `text`'s characters (`n` < its length), `at` the batch's earliest delivery in it (none:
616    /// the newest), each cut marked `…`. `after`: from `at` on, its start first, or the newest `n` when
617    /// what follows `at` is shorter; `ends`: half from `at`, half the end, or `after`'s when the two meet.
618    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
635/// How long one session-end judge call (`judge_session`) may take: a learn-side call's limit
636/// (`BORG_LEARN_TIMEOUT_S`), at most twice the check's (`BORG_PREDICT_TIMEOUT_S`). Derived: the session
637/// lock it holds reads stale after twice the check's limit plus a slack (live.rs `stale_ms`); the
638/// pass renews the lock before each call (live.rs `Guard::renew`), so a stop of a session resumed
639/// meanwhile never takes the lock over and judges the same deliveries again.
640fn end_timeout() -> std::time::Duration {
641    learn::learn_timeout().min(predict::timeout().saturating_mul(2))
642}
643
644/// After a session-end judge call, in the state file: `#borg-end-judged <ids>`, the lessons it was
645/// shown. Their latest deliveries are settled by it whatever it said (`pending`), so neither a later
646/// check nor a second session-end pass judges them again; one it left pending settles at `borg learn`
647/// as having used its tries (`unjudged`). A later delivery of the lesson opens it again.
648const END_JUDGED: &str = "#borg-end-judged ";
649
650/// When a judge call runs (`pending`), which decides what it judges: a turn end (a stop's check) or the
651/// session's end (`judge_session`).
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653enum When {
654    Stop,
655    End,
656}
657
658/// The markers of a delivery of lessons to the drone, in the state and .edits files: asked at a Stop
659/// (`#borg-asked`; live, before 0.7.6, with a `#borg-live` line after it), delivered early, handed over
660/// free (`#borg-queued`); and a placebo (`#borg-placebo`), never shown but judged as a free delivery is.
661/// Each ends in ` @<check>`, the check that routed its lessons (select.rs `check_id`), once checks had ids.
662const DELIVERED: [&str; 4] = ["#borg-asked ", "#borg-early ", "#borg-queued ", "#borg-placebo "];
663
664/// Right after a stop's `#borg-asked` line: `#borg-routed <id>=<check>,…`, the lessons of the block
665/// another check routed (a live check's ask still waiting at the stop, before 0.7.6), where the block's
666/// own tag is the stop's check. Read, no longer written.
667const ROUTED: &str = "#borg-routed ";
668
669/// Whether `line` is part of a delivery's markers (`DELIVERED`, `#borg-live`, `ROUTED`): one delivery
670/// writes its work line once, then a marker per routing check.
671fn 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/// Whether the judge sees question `lesson` of `check` without the drone's `<id>: applies|skip`: a
676/// fair coin from blake3 over both, so a retry strips alike and a replay knows it (outcome.rs
677/// `stripped`; a test predicts a judge prompt by it). Method: a random half, to measure what the
678/// declaration adds to a verdict.
679#[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
687/// Of the lessons delivered in a session's state file `state`, since each one's latest delivery: how many
688/// verdicts it had (its tries) and whether one settled it (a label other than pending, or a session-end
689/// judge's `END_JUDGED`). A delivery opens its lesson again.
690fn 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
712/// The lessons of `state` whose latest delivery no verdict settled yet: what a session-end judge
713/// (`judge_session`) judges, all of them.
714fn 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
721/// What one judge call takes (`pending`): the pending lessons with their evidence, whether a question
722/// is among them, and the ids the call has no slot for (`MAX_PENDING`; none at the session's end).
723struct Judging<'a> {
724    pending: Vec<predict::Pending<'a>>,
725    question: bool,
726    crowded: Vec<String>,
727}
728
729/// R1: lessons delivered earlier in this session (asked at a Stop, or live before 0.7.6, delivered early,
730/// handed over free, or a placebo) that have no verdict yet, with the evidence for the consequence model
731/// (predict.rs `Pending`), the same for every route (judge v2): the work before the delivery (at a Stop the
732/// blocked message, before a free delivery or a placebo the work so far), and after it "work since" (a
733/// question's answer, then the drone's edits and later messages) and this turn's message (`final_msg`),
734/// each capped by `predict::JUDGE_PART`. A random half of the questions (`stripped`) lose the drone's
735/// declaration from all of the call's evidence: their own, every sibling's, and this turn's message, which
736/// may be the answer itself (`check` takes it out of the work the call shows too). `#borg-judged
737/// <id>=<label>` lines in the state file record verdicts, each for the lesson's latest delivery before it:
738/// a lesson delivered early, judged, then asked at a Stop is judged again on the question. A delivery still
739/// open after a stop's call (or at a stop that makes none) waits for a later stop, and at the latest for
740/// the session-end judge (`judge_session`). Of the `MAX_PENDING` slots, questions take theirs first (the
741/// newest), free deliveries what is left: a free hand-over never pushes a question out of its judge. At the
742/// session's end (`When::End`) nothing is held, a lesson's tries do not bar it (`MAX_JUDGE_TRIES`) and
743/// there are no slots: every open delivery is judged, in batches (`batches`), in delivery order (the order
744/// of the result). A lesson a session-end judge was shown (`END_JUDGED`) is settled for every later call.
745/// `judging`: this call's check, the coin's seed for a delivery from before checks had ids.
746fn 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
750/// `pending` with the .edits file read up to `edits` (`edits_upto`): a detached check's, as at its stop.
751fn 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    /// One delivery's evidence, unclipped, and its fresh lessons: (id, routing check, stripped).
765    struct Delivery<'l> {
766        channel: &'static str,
767        /// A question (`#borg-asked`), not a free delivery or a placebo.
768        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        // The work before: the line before this delivery's markers, a delivery's own work line (`WORK`)
788        // read as its text; nothing when that is another bookkeeping line.
789        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        // A delivery without a fresh lesson adds nothing: skipped before its evidence is gathered, the call
803        // is O(open deliveries × session), not O(every delivery × session).
804        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    // Every stripped question's declaration leaves all of this call's evidence before any cut could halve
815    // one: its own, a sibling's (a later blocked message may hold the answer) and this turn's message.
816    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    // Each with whether it was a question.
820    let mut out: Vec<(predict::Pending, bool)> = Vec::new();
821    for d in deliveries {
822        // One "after" for the whole delivery, so lessons delivered together share it.
823        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            // A later delivery of the same lesson is the one judged: its evidence replaces the earlier one's.
831            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
856/// `text` without the declarations of the stripped questions among `pending` (`stripped`): the work a
857/// judge call shows beside them, which may hold the answer to one (this turn's message at the stop).
858fn 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
862/// `#borg-unjudged <ids> why=<reason>`: pending lessons a judge call did not judge, and why
863/// (outcome.rs `UNJUDGED_SLOTS`, `UNJUDGED_FAILED`), so `borg learn` can say why each settled without
864/// a verdict (`unjudged`). Nothing for no ids.
865fn 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
871/// Why each lesson delivered in a session's state file `state` has no verdict, as `borg learn` settles
872/// it (outcome.rs `settle_with`): its tries ran out on unclear or missing verdicts since its latest
873/// delivery, the session-end judge's included (`END_JUDGED`; `tries`), else the last judge call that
874/// left it out (`slots`, `failed`), else no call came after its delivery (`no_call`).
875pub(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            // The session-end judge was its last try, whatever its count.
895            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
916/// R1: the consequence model's verdicts on the pending lessons it was shown (predict.rs `Judged`), each
917/// recorded under its own kind (predict.rs `Verdict::kind`): `asked_then_changed` for a change that
918/// avoids the consequence, `asked_ignored` for no change on a point that came up, `asked_not_arisen`
919/// for a point that never came up (censored for beliefs); these three settle the delivery
920/// (`#borg-judged <id>=<verdict>`), a `not_arisen` only at the session's end (`at` outcome.rs
921/// `AT_END`): at a check the point may still come up later, so it stays pending for later stops and
922/// the session-end judge (`judge_session`), a try as an unclear one is. An unclear verdict (`asked_unclear`) and a change that still leads
923/// to the consequence (`asked_consequence_remains`) are measurements only and stay pending
924/// (`#borg-judged <id>=pending`), as a missing verdict does. Each result names its delivery's channel
925/// (outcome.rs `PLACEBO` for a placebo, never a result about the lesson), the check that routed it, the
926/// judge's version (predict.rs `JUDGE_VERSION`), whether the declaration was stripped, per lesson the
927/// judge's redacted reason (`reasons`), the session's `repo`, and where it was judged (`at`: empty at a
928/// check, outcome.rs `AT_END` at the session's end). Logs the call's judging cost in the .cost file
929/// (`judge_line`, then ` model=<judge>`, then ` at=<at>` when not at a check, then `logged`'s fields).
930fn record_verdicts(session: &str, repo: &str, at: &str, pending: &[predict::Pending], pred: &predict::Prediction, logged: &Logged) {
931    // One record per (kind, channel, check, stripped): the statistics split what the deliveries led to by
932    // how they were made. Each lesson with its reason.
933    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        // A point that has not come up by a check may still come up later in the session: only the
938        // session-end judge's `not_arisen`, with the whole session in view, settles the delivery.
939        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        // The statistics count one event per kind and channel, whatever checks routed its lessons.
953        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                // None at all: left out, as an older record.
969                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
983/// What a judge call's cost line says besides its verdicts (`record_verdicts`): the call's number
984/// among the session's model calls, its prompt's characters (`judge_line`), the judge's model
985/// (`judge_model`), and fields of its own after ` at=` (a check's whole prompt's characters, `prompt=`; a
986/// session-end batch's `batch=`, `budget=`, `psi=` and `explore=` when it drew, `cut=`).
987struct Logged {
988    call: usize,
989    chars: usize,
990    model: String,
991    fields: String,
992}
993
994/// A judge call's cost line (`record_verdicts`): `judge call=<nth model call of the session> pending=N
995/// resolved=K chars=<prompt chars added>`, K the verdicts that settle (changed, ignored, not_arisen);
996/// then the ids the answer named (`returned=a,b`, predict.rs `Prediction::returned`) and those of them
997/// no pending lesson has (`unmatched=`), each empty for none; then the verdicts on pending lessons by
998/// word, every word in `Verdict::WORDS` order (`verdicts=changed:1,ignored:0,…`), and the characters
999/// of their reasons (`reason_chars=`, what the answer added). A pending lesson in neither list was left
1000/// out by the model; one returned but not resolved got a malformed verdict. Zero tokens, no prompt text.
1001fn 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
1016/// The session-end judge (0.7.6), run by `borg learn --session` before it learns (main.rs `learn_cmd`):
1017/// every delivery of `session` (any channel) whose latest delivery no verdict settled yet (none came, or
1018/// only unclear, consequence_remains or missing ones, its tries spent or not) is judged, beside as much of
1019/// the session's work as the prompt's budget leaves (`end_budget`, `session_sources`, `within`). The
1020/// lessons go in batches by delivery order, each prompt within the budget (`batches`), each batch its
1021/// own model call, `runner::JOBS` at a time, each within `end_timeout`, under the session lock,
1022/// renewed before each call (live.rs `Guard::renew`). Each batch records as soon as its call returns,
1023/// while the others still run, so a pass stopped partway keeps what it was already told (and paid for).
1024/// An answered batch records as a check's judge does (`record_verdicts`, `at` outcome.rs `AT_END`, its
1025/// cost line naming `batch=<i>/<n>`, `budget=` and `cut=`) and writes the state lines `#borg-judged`,
1026/// then its own `END_JUDGED`: what it judged settles there, and `borg learn` then settles the rest as
1027/// `asked_not_corrected` with its reason (`unjudged`). Each call is counted as a `model_call` with why
1028/// `judge`. A failed batch is no try, for its own lessons alone (`#borg-unjudged … why=failed`), and its
1029/// cost line `judge-failed` keeps its lessons as sent and unanswered for the judge's effective length
1030/// (judge_length.rs); after `runner::MAX_FAILURES` failed calls before the first answer the judge is
1031/// unavailable, and the batches not yet asked fail alike, without a call. A pass whose lock was taken over
1032/// asks nothing more; what it did not ask stays open for the lock's new holder, and `borg learn` settles
1033/// no session whose lock another holds (learn.rs). A lock held by another (a stop, or an earlier pass
1034/// of a resumed session) is waited for, as long as it is renewed: it reads stale `live::stale_ms`
1035/// after its holder stops. Nothing runs, and nothing is written, without learning (`BORG_LEARN=off`),
1036/// without a runner for the consequence model (`BORG_PREDICT_MODEL=off`, none installed), or with
1037/// nothing open. Returns how many lessons it judged.
1038pub 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    // A stop, or an earlier pass of this session (a resumed session's end), judges the same lessons:
1053    // wait for it while it holds the lock. A pass renews it before each call, so it may hold it longer
1054    // than one wait; a holder that stops renewing lets it go stale within one.
1055    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    // The store's judge cost lines are beside the session's: its .cost file is in the same directory.
1071    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    // The judge's call numbering counts every answered model call of the session: the checks', then these
1080    // in the order they answer (`batch=` names each one's place).
1081    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                    // Taken over: another holds the session now. Not written (I/O): still ours, ask on.
1094                    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        // One writer: each batch records here as its call returns, while the others still run.
1108        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                // Sent and not answered: the judge's effective length counts it (judge_length.rs).
1125                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    // Not asked: the judge was unavailable (failed, no try), or the lock was lost (open for its holder).
1136    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
1144/// The session-end judge's batches of `pending` (`judge_session`, in delivery order), as ranges of it:
1145/// each takes the next lessons while their judge section (`predict::judge_section`) stays within
1146/// 1/`END_LESSONS_SHARE` of `budget`, and at least one. Consecutive, so each batch covers its own span of
1147/// the session, from its earliest delivery on. A lesson whose evidence alone exceeds that share goes
1148/// alone (its evidence is bounded by `predict::JUDGE_PART`).
1149fn 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
1165/// One session-end batch's call (`ask_batch`): its prompt's characters (`predict::judge_prompt`), whether
1166/// the session's work was cut to fit, the judge's answer (None: failed) and how long it took.
1167struct Batch {
1168    chars: usize,
1169    cut: bool,
1170    pred: Option<predict::Prediction>,
1171    ms: u64,
1172}
1173
1174/// Asks the session-end judge about `batch` (`judge_session`): beside its lessons, the session's work
1175/// in what `budget` leaves (`within`), cut by `cut` at the batch's earliest delivery, within
1176/// `end_timeout`.
1177fn 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
1188/// One source of the session's work as the session-end judge reads it (`session_sources`): its label,
1189/// its text, and the delivery markers (`DELIVERED`) that stood in it: where (a character offset of the
1190/// text) and the lessons each delivered.
1191struct Source {
1192    label: &'static str,
1193    text: Vec<char>,
1194    marks: Vec<(usize, Vec<String>)>,
1195}
1196
1197impl Source {
1198    /// The lines of `text` that `keep` gives, each delivery marker a mark between them; each run of lines
1199    /// between two marks without the declarations of the stripped questions among `pending`
1200    /// (`without_declarations`). Empty when only whitespace is left.
1201    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                // Where the next run starts: after the newline that will set it apart.
1209                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    /// Appends a run of `lines`, on lines of their own, without the stripped declarations.
1222    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    /// Where the earliest of `ids`' latest deliveries stood in the text (the delivery each is judged on,
1233    /// `pending`); None when no marker names one.
1234    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
1239/// The session's whole work as the session-end judge reads it (`judge_session`), by source: the
1240/// engineer's prompts and the agent's touches (the state file without its bookkeeping), the agent's
1241/// edits and its answers to questions (the .edits file), and its messages (its harness's transcript,
1242/// harness.rs `session_texts`, where it writes one: its newest `cap` characters, more than any batch
1243/// reads). Each without the declarations of the stripped questions among `pending`
1244/// (`without_declarations`); the first two with where each delivery stood in them (`Source`).
1245fn 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
1255/// `sources` in `budget` characters in all, framing included, each as `### <label>` and its text: all
1256/// whole when they fit; else each an equal share of what is left, the shortest first, so a part shorter
1257/// than its share leaves the rest to the others, and a longer one is cut by `cut` (`Cut::window`) at its
1258/// `anchors` entry (the batch's earliest delivery in it). An empty part is left out. With whether a part
1259/// was cut.
1260fn 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    // `### <label>\n` and the closing newline of each part; a cut part's marks (`…`), two at most.
1264    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
1290/// The repo the session works in: the latest repo the hook noted at a prompt (`.where`, a repo name on
1291/// a line of its own, systems.rs `work_repos`); empty when none.
1292fn 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
1297/// Ids listed on the state file's `marker` lines (`#borg-asked a,b @<check>`).
1298pub(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
1302/// The ids of a marker's or a queue line's list, without its ` @<check>` tag (`tagged`).
1303fn ids_in(list: &str) -> impl Iterator<Item = &str> {
1304    tagged(list).0.split(',').map(str::trim).filter(|i| !i.is_empty())
1305}
1306
1307/// ` @<check>` after a list, or nothing for a check without an id (a line of an older binary's state).
1308fn tag(check: &str) -> String {
1309    if check.is_empty() { String::new() } else { format!(" @{check}") }
1310}
1311
1312/// Early delivery (conscience.rs `early`): once the session's prompts and touches name a lesson scope
1313/// (a topic) and that scope's lessons score on them, the top ones go to the drone at once, as context,
1314/// instead of waiting for the Stop checkpoint: at the prompt or the first touch on the topic (`text` is
1315/// this event's, already in the state file). Each lesson once per session (`#borg-early`; Stop does not
1316/// ask it again) and an `asked` outcome with channel early (`record_delivery`). A topic is settled (`#borg-topic`) once a delivery covered it or none of its lessons is
1317/// left; until then each prompt or touch looks again. At most `MAX_EARLY_DELIVERIES` per session. Only
1318/// where the harness takes it (`Caps::early_delivery`, Claude Code so far): omp and opencode take a prompt's
1319/// and a tool call's context too, but early delivery to them waits for an evaluation, so for
1320/// them the lessons stay with Stop. Standing lessons (ratified constraints, conscience.rs `standing_pool`)
1321/// are no ranking guess and need no evaluation: they come on every harness that takes the context (a
1322/// prompt's, or a tool call's for a named scope), whenever their scope is named (an unscoped one at a
1323/// prompt), settled topic or not and past `MAX_EARLY_DELIVERIES`, until each came once. `BORG_EARLY=off`
1324/// disables it; so do the eval modes BORG_ONLY and BORG_CRITIC.
1325/// Early deliveries per session at most (each up to `conscience::render_early`'s lesson cap). Guard: a
1326/// long session naming many systems would otherwise get a delivery at every touch until each topic's
1327/// lessons ran out (standing lessons are exempt).
1328const 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    // Free, but not free of noise: a long session naming many systems would otherwise get a delivery
1341    // at every touch until each topic's lessons ran out. Standing lessons are exempt.
1342    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    // Consolidated lessons (memory.rs) need no scope: the first prompt is their topic (`ANY`).
1351    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    // A standing lesson's topic is every named scope, settled or not, and any prompt for an unscoped one.
1360    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    // The header names what came: the plain topics, and the scopes of the standing lessons beside them.
1370    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    // Today's early caps (`EARLY_CAP`, `EARLY_CHARS`, `RELATIVE`), kept until a learned early rule has
1377    // data: each early delivery's record carries every lesson's score relative to the best (`early_rel`).
1378    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    // Plain topics only: a standing lesson left over (past `EARLY_CAP` or `EARLY_CHARS`) comes at the next
1382    // prompt or touch on its scope whatever is settled.
1383    let now_settled: Vec<&str> = topics
1384        .iter()
1385        .filter(|t| {
1386            // One delivery per topic: whatever was delivered now covers every topic looked at now.
1387            !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    // An early delivery is its own decision: its id ties its markers to its record.
1403    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
1419/// Each of `delivered`'s word-match score (conscience.rs `rank` over `work`) as a share of the best
1420/// on-topic match of `topics`, done or not (the match `RELATIVE` bars against: `early_pool` over no done
1421/// lesson starts with it), rounded as a propensity is: what a learned early rule needs of each early
1422/// delivery (outcome.rs `rel`). A standing lesson's too, though no score gates it (0 when it matches no
1423/// word). Empty when no plain lesson is on topic to scale by (a standing lesson's delivery alone).
1424fn 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
1430/// A delivery's markers (`lines`: a free delivery's, a placebo's) in the .edits file, in
1431/// one write after the work so far (`WORK`): the evidence before it is the same whatever the route
1432/// (`pending`), and no route's line reads as work after an earlier delivery.
1433fn 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
1439/// The `asked` outcome of a free delivery (`channel` early or queued) of `delivered`, routed by `check`,
1440/// with each one's propensity `p` and, early, its relative score `rel` (`early_rel`): delivered as
1441/// context, the lessons wait for a result as a Stop's questions do. The next judge (`pending`) labels
1442/// them changed or ignored, a merged correction corrected (outcome.rs `contradicted`), and `borg learn`
1443/// settles the rest. Nothing with learning off.
1444fn 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
1469/// One `delivery` record (outcome.rs `DELIVERY`): what delivery did with `lessons`, routed by `check`:
1470/// `route` (outcome.rs `ROUTES`) and why. Nothing with learning off or without lessons.
1471fn 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
1490/// `record_route` for each check of `routed` (lesson, check) pairs, in first-seen order.
1491fn 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
1497/// `routed` (lesson, check) pairs grouped by check, checks in first-seen order, lessons in theirs.
1498fn 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
1509/// Graded Stop (default): what a graded check left for later, one line per check, waiting for the session's
1510/// next prompt or touch (`queued`): `<ids> @<check>`, the 2s and 1s of a check that did not block, to hand
1511/// over free; `placebo <ids> @<check>`, a check's placebo draws (select.rs `Routed`); and `instead-next
1512/// <ids> @<check>`, a check's free lessons handed over in place of questions the fixed rule asks
1513/// (`INSTEAD_NEXT`); and `live <ids> @<check>`, a detached check's asks (`ASK_NEXT`, check_async.rs).
1514/// A line of an older binary has no tag, and may be a live check's (`ASK_NEXT`, `LEGACY_INSTEAD`).
1515fn queued_file(session: &str) -> PathBuf {
1516    state_file(session).with_extension("queued")
1517}
1518
1519/// What a queue line routes its lessons to (`queued_file`), in the order delivery takes them (`queued`).
1520#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1521enum Queued {
1522    /// A detached check's ask, or a live check's from before 0.7.6 (`ASK_NEXT`): handed over, recorded as
1523    /// the ask it was.
1524    Ask,
1525    /// Free, in place of a question the fixed rule asks (`INSTEAD_NEXT`, `LEGACY_INSTEAD`).
1526    Instead,
1527    Free,
1528    Placebo,
1529}
1530
1531/// Protocol: the prefix of the free lessons a check hands over in place of questions the fixed rule
1532/// asks (`queued_file`): the evidence rule's on a check with a 3 (select/policy.rs `EVIDENCE`), which
1533/// valued them as hand-overs (their chance of arriving included), and any of a stop's. A free delivery
1534/// carries them whatever its budget (`queued`, conscience.rs `render_owing`), as the questions they
1535/// replace would have been carried; no stop asks them.
1536const INSTEAD_NEXT: &str = "instead-next ";
1537
1538/// Protocol: the prefixes of the asks a check queues for the next prompt or tool call: a detached
1539/// check's (check_async.rs, `live <ids> @<check>`, since 0.7.7), and a live check's from before 0.7.6
1540/// (live mode is gone; a session that ran across that upgrade may still have one waiting). The next
1541/// prompt or tool call hands them over, all and first, and records each as the ask its check decided
1542/// (channel live, `#borg-asked` then `#borg-live`, as 0.7.5 did), so the selection fold and the judge
1543/// join it; no stop blocks for them. A detached check's come as questions about the turn it judged
1544/// (`PROPOSAL`, conscience.rs `render_late`); a live check's with the free lessons, as 0.7.5 did.
1545const ASK_NEXT: [&str; 2] = ["live ", "live-next "];
1546
1547/// Protocol: the prefix of a live check's question-count test hand-overs in place of questions, from
1548/// before 0.7.6: free, as an `INSTEAD_NEXT` line is.
1549const LEGACY_INSTEAD: &str = "instead ";
1550
1551/// A queue line's route, ids and check (empty on an older binary's line).
1552fn 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
1564/// `lines` at the end of the `.queued` file in one write under its lock (live.rs `locked`): never into a
1565/// file a hook is taking at the same moment. A detached check's (`offset`, its stop's state length) only
1566/// while its session lives (`gone`), read under that lock, which the end hook reads the queue under
1567/// (`note_never_delivered`): false when it ended, nothing written then.
1568fn 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
1583/// The whole `.queued` file, taken under its lock (a rename, then read): what a check appends after
1584/// starts a new file, delivered next time.
1585fn 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
1594/// The lessons a `.queued` file's `text` holds to be handed over (every line but a placebo's): decided
1595/// already by the check that queued them, so no other check decides them again (the stop's pool,
1596/// `decide`'s `had`). A placebo waiting was routed nowhere: it stays eligible. For a detached check
1597/// (`regraded`) so does a waiting ask (`ASK_NEXT`): it grades it again against newer work and takes its
1598/// line off the queue (`supersede`), so a question never reaches the drone about work it has since
1599/// moved past. A stop's own check leaves it to its line, as before 0.7.7.
1600fn 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
1604/// A detached check's step before it queues its own (`settle`): the waiting asks of earlier checks
1605/// (`ASK_NEXT` lines) that it showed its model again (`shown`) leave the queue: superseded when it asks
1606/// them too (its own line carries them; outcome.rs `ROUTE_SUPERSEDED`), else settled by the later work
1607/// it graded (`ROUTE_DROPPED_DONE`); each with its `delivery` record. The `.queued` file is rewritten whole under its
1608/// lock (live.rs `locked`), so no hook takes it half-written. The ids that left.
1609fn supersede(session: &str, input: &Value, shown: &[&conscience::Challenge], asks: &[&conscience::Challenge]) -> Vec<String> {
1610    let q = queued_file(session);
1611    // (check, ids, why) of each waiting ask taken off.
1612    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
1654/// Protocol: the .edits line a detached check writes beside its asks (`settle`), `#borg-proposal
1655/// <check> <the drone's message it judged, on one line>`: what their delivery (`queued`) names as the
1656/// turn the questions are about (conscience.rs `render_late`) and gives the judge as the work before them
1657/// (`pending`), as a block's blocked message is. A bookkeeping line: no reader takes it for work.
1658const PROPOSAL: &str = "#borg-proposal ";
1659
1660/// The excerpt of the judged message a detached check's questions name (conscience.rs `render_late`), in
1661/// characters. Display: enough of a message's start to recognise the turn.
1662const 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
1670/// The message `check` judged, from its `PROPOSAL` line in the .edits file's `edits`; None for a check
1671/// that wrote none (a live check's ask from before 0.7.6).
1672fn 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
1679/// A detached check's asks of `check` delivered (`members`, the drone's message it judged `said`), as
1680/// `queued` and a wake (listen.rs, `commit_asks`) mark them: `#borg-asked` then `#borg-live` (with `via`
1681/// when a wake carried them) in the state file, the pair after the judged message in the .edits file,
1682/// the work before the questions (`pending`), so the next stop records the drone's answer
1683/// (`note_answer`); each recorded as asked on channel live (`record_asked`); the cost line, `event` the
1684/// hook's or `wake`; and the stat.
1685fn 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        // A detached check's asks (live mode's, before 0.7.6, had no mode): stats.rs folds them apart.
1701        e.mode = crate::check_async::MODE.into();
1702    });
1703}
1704
1705/// Protocol: the event a wake's delivery of asks is logged under (`mark_late`: the cost line's `event=`,
1706/// `#borg-live … via=wake`, the stat's why).
1707const WAKE_EVENT: &str = "wake";
1708
1709/// Protocol: the id a wake and an answer's `shown_ids` name a detached check's ask by,
1710/// `L@<check>:<lesson>` (listen.rs): beside message ids (msg.rs `is_id`), never one.
1711#[must_use]
1712pub(crate) fn lesson_wake_id(check: &str, lesson: &str) -> String {
1713    format!("L@{check}:{lesson}")
1714}
1715
1716/// The check and the lesson of a wake id (`lesson_wake_id`); None for any other id.
1717#[must_use]
1718pub(crate) fn lesson_of_wake_id(id: &str) -> Option<(&str, &str)> {
1719    id.strip_prefix("L@")?.split_once(':').filter(|(check, lesson)| !check.is_empty() && !lesson.is_empty())
1720}
1721
1722/// The asks a wake has taken from the `.queued` file and not yet delivered (`reserve_asks`):
1723/// `<s>.queued.wake`, `ASK_NEXT` lines, under the `.queued` file's lock. Delivered by the wake
1724/// (`commit_asks`), or put back (`release_asks`) when the wake never went in.
1725fn reserved_file(session: &str) -> PathBuf {
1726    state_file(session).with_extension("queued.wake")
1727}
1728
1729/// The detached checks' asks waiting in the `.queued` file (`ASK_NEXT` lines whose check left its judged
1730/// message, `PROPOSAL`), as `queued` would deliver them: each check with its lesson ids, in queue order.
1731/// A wake carries them (listen.rs).
1732#[must_use]
1733pub(crate) fn asks_waiting(session: &str) -> Vec<(String, Vec<String>)> {
1734    let text = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1735    if !text.lines().any(|l| queue_line(l).0 == Queued::Ask) {
1736        return Vec::new();
1737    }
1738    let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1739    let mut out: Vec<(String, Vec<String>)> = Vec::new();
1740    for (route, ids, check) in text.lines().map(queue_line) {
1741        if route != Queued::Ask || proposal_of(&edits, check).is_none() {
1742            continue;
1743        }
1744        match out.iter_mut().find(|(c, _)| c == check) {
1745            Some((_, have)) => {
1746                for id in ids {
1747                    if !have.iter().any(|h| h == id) {
1748                        have.push(id.to_string());
1749                    }
1750                }
1751            }
1752            None => out.push((check.to_string(), ids.into_iter().map(String::from).collect())),
1753        }
1754    }
1755    out
1756}
1757
1758/// A wake's asks, taken off the `.queued` file into the reserved file (`reserved_file`) under the
1759/// queue's lock, so no prompt delivers them meanwhile (`queued`); with the text the wake carries
1760/// (conscience.rs `render_late` per check, as `queued` renders them) and their wake ids
1761/// (`lesson_wake_id`). Only the asks of `wanted` still waiting, deliverable and not done
1762/// (`done_in`, as `queued` drops them) are taken, each lesson once: the rest stay for the next prompt's
1763/// delivery and its records. None when none is taken, and while a detached check of the session runs
1764/// (check_async.rs `running`): it may grade a waiting ask again against newer work (`supersede`), so a
1765/// wake never carries a question that work settled; the waiter looks again once that check writes
1766/// `<s>.checked`.
1767#[must_use]
1768pub(crate) fn reserve_asks(session: &str, wanted: &[(String, Vec<String>)]) -> Option<(String, Vec<String>)> {
1769    if crate::check_async::running(session) {
1770        return None;
1771    }
1772    let all = lessons(session);
1773    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1774    let done = done_in(&state, &early_acted(session, &all, "", None));
1775    let ok =
1776        |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);
1777    let q = queued_file(session);
1778    let mut taken: Vec<(String, Vec<String>)> = Vec::new();
1779    live::locked(&q, || {
1780        let Ok(text) = std::fs::read_to_string(&q) else { return };
1781        let mut kept = String::new();
1782        let mut moved = String::new();
1783        // A lesson waiting under two checks' lines goes once (the first); the other line keeps it, and
1784        // the prompt drops it there as delivered (`queued`).
1785        let mut once: std::collections::HashSet<&str> = std::collections::HashSet::new();
1786        for line in text.lines() {
1787            let (route, ids, check) = queue_line(line);
1788            let (mine, rest): (Vec<&str>, Vec<&str>) = ids.iter().copied().partition(|i| route == Queued::Ask && ok(check, i) && once.insert(*i));
1789            let Some(prefix) = ASK_NEXT.iter().find(|p| line.starts_with(**p)).filter(|_| !mine.is_empty()) else {
1790                kept.push_str(&format!("{line}\n"));
1791                continue;
1792            };
1793            moved.push_str(&format!("{prefix}{}{}\n", mine.join(","), tag(check)));
1794            if !rest.is_empty() {
1795                kept.push_str(&format!("{prefix}{}{}\n", rest.join(","), tag(check)));
1796            }
1797            taken.push((check.to_string(), mine.into_iter().map(String::from).collect()));
1798        }
1799        if taken.is_empty() {
1800            return;
1801        }
1802        let r = reserved_file(session);
1803        let wrote = std::fs::OpenOptions::new().create(true).append(true).open(&r).and_then(|mut f| f.write_all(moved.as_bytes()));
1804        let tmp = q.with_extension(format!("queued.{}.new", std::process::id()));
1805        let swapped =
1806            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)) });
1807        if swapped.is_err() {
1808            // The queue stays whole: the reserved copy goes, nothing is taken.
1809            let _ = std::fs::remove_file(&tmp);
1810            let _ = unreserve(&r, &moved);
1811            taken.clear();
1812        }
1813    });
1814    if taken.is_empty() {
1815        return None;
1816    }
1817    let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1818    let mut parts = Vec::new();
1819    let mut wake = Vec::new();
1820    for (check, ids) in &taken {
1821        let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| all.iter().find(|c| &c.id == i)).collect();
1822        parts.push(conscience::render_late(&members, &one_line(proposal_of(&edits, check).unwrap_or_default(), LATE_EXCERPT)));
1823        wake.extend(ids.iter().map(|id| lesson_wake_id(check, id)));
1824    }
1825    Some((parts.join("\n\n"), wake))
1826}
1827
1828/// `moved` (lines of the reserved file `r`) out of it again: the rest of `r` stays.
1829fn unreserve(r: &Path, moved: &str) -> std::io::Result<()> {
1830    let text = std::fs::read_to_string(r).unwrap_or_default();
1831    let rest = text.strip_suffix(moved).unwrap_or(&text);
1832    if rest.is_empty() { std::fs::remove_file(r) } else { std::fs::write(r, rest) }
1833}
1834
1835/// Takes the reserved asks the wake ids `ids` name (`lesson_wake_id`; None: all) off the reserved file,
1836/// under the queue's lock: each check's ids taken, in reserve order. `then` carries them out under the
1837/// same lock, before they leave the reserved file: a reader without the lock (`askable`, `decide`'s
1838/// `had`) finds each ask reserved, delivered or queued again, never in none of them. `then` must not
1839/// take the queue's lock (an flock held twice by one process waits on itself). When `then` fails (false:
1840/// a full or read-only state directory) the asks stay reserved, none taken: a release that wrote nothing
1841/// never loses them (reclaimed later, or recorded `never_delivered` at the session's end).
1842fn take_reserved(session: &str, ids: Option<&[String]>, then: impl FnOnce(&[(String, Vec<String>)]) -> bool) -> Vec<(String, Vec<String>)> {
1843    let q = queued_file(session);
1844    let r = reserved_file(session);
1845    let named = |check: &str, id: &str| ids.is_none_or(|ids| ids.iter().any(|w| lesson_of_wake_id(w) == Some((check, id))));
1846    let mut taken: Vec<(String, Vec<String>)> = Vec::new();
1847    live::locked(&q, || {
1848        let Ok(text) = std::fs::read_to_string(&r) else { return };
1849        let mut kept = String::new();
1850        for line in text.lines() {
1851            let (_, ids, check) = queue_line(line);
1852            let (mine, rest): (Vec<&str>, Vec<&str>) = ids.iter().copied().partition(|i| named(check, i));
1853            if !rest.is_empty() {
1854                kept.push_str(&format!("{}{}{}\n", ASK_NEXT[0], rest.join(","), tag(check)));
1855            }
1856            if !mine.is_empty() {
1857                taken.push((check.to_string(), mine.into_iter().map(String::from).collect()));
1858            }
1859        }
1860        if taken.is_empty() {
1861            return;
1862        }
1863        if !then(&taken) {
1864            taken.clear();
1865            return;
1866        }
1867        let _ = if kept.is_empty() { std::fs::remove_file(&r) } else { std::fs::write(&r, &kept) };
1868    });
1869    taken
1870}
1871
1872/// A wake that went in (listen.rs) delivered the reserved asks `ids` names (`lesson_wake_id`): each
1873/// check's marked and recorded as `queued` marks a delivered one (`mark_late`, event `wake`; `cwd` the
1874/// session's, for the records' repo), then off the reserved file. The lessons it delivered; none already
1875/// taken (an earlier answer, `release_asks`).
1876pub(crate) fn commit_asks(session: &str, cwd: &str, ids: &[String]) -> Vec<String> {
1877    let all = lessons(session);
1878    let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
1879    let input = json!({"session_id": session, "cwd": cwd});
1880    let mut out = Vec::new();
1881    take_reserved(session, Some(ids), |taken| {
1882        for (check, ids) in taken {
1883            let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| all.iter().find(|c| &c.id == i)).collect();
1884            let said = proposal_of(&edits, check).unwrap_or_default();
1885            let chars = conscience::render_late(&members, &one_line(said, LATE_EXCERPT)).len();
1886            mark_late(session, &input, check, &members, said, WAKE_EVENT, chars);
1887            out.extend(members.iter().map(|c| c.id.clone()));
1888        }
1889        true
1890    });
1891    out
1892}
1893
1894/// The reserved asks `ids` names (None: all of them) back on the `.queued` file: the wake never went
1895/// in, so the next prompt or wake delivers them. At a waiter's start and end every reserved ask goes
1896/// back (a waiter that ended or died with one reserved; `reclaim_reserved`). The wake ids put back; none
1897/// when the queue could not be written (they stay reserved, `take_reserved`).
1898pub(crate) fn release_asks(session: &str, ids: Option<&[String]>) -> Vec<String> {
1899    let q = queued_file(session);
1900    let taken = take_reserved(session, ids, |taken| {
1901        // Appended as `enqueue_all` does, under the lock `take_reserved` holds.
1902        let lines: String = taken.iter().map(|(check, ids)| format!("{}{}{}\n", ASK_NEXT[0], ids.join(","), tag(check))).collect();
1903        std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(lines.as_bytes())).is_ok()
1904    });
1905    taken.iter().flat_map(|(check, ids)| ids.iter().map(|id| lesson_wake_id(check, id))).collect()
1906}
1907
1908/// The reserved asks back on the queue (`release_asks`) when no waiter of the session runs (listen.rs
1909/// `waiter_runs`): one that ended or died with a wake unanswered never delivers them, and until they are
1910/// back no prompt delivers them and no check grades them again (`askable`). At a prompt or tool call
1911/// (`queued`) and a detached check's stop.
1912fn reclaim_reserved(session: &str) {
1913    if reserved_file(session).exists() && !crate::listen::waiter_runs(&learn::home(), session) {
1914        release_asks(session, None);
1915    }
1916}
1917
1918/// At the session's end: whatever still waits in the `.queued` file was never delivered
1919/// (`never_delivered`, outcome.rs `DELIVERY`). Read, not taken: a resumed session still gets it at its
1920/// next prompt, and that delivery's record supersedes this one.
1921fn note_never_delivered(session: &str, input: &Value) {
1922    // And the asks a wake reserved that never went in (`reserve_asks`). Under the queue's lock: a
1923    // detached check queues before it (and is read here) or finds the session ended (`enqueue_all`).
1924    let text = live::locked(&queued_file(session), || {
1925        std::fs::read_to_string(queued_file(session)).unwrap_or_default() + &std::fs::read_to_string(reserved_file(session)).unwrap_or_default()
1926    });
1927    for line in text.lines() {
1928        let (route, ids, check) = queue_line(line);
1929        let channel = match route {
1930            Queued::Ask => "live",
1931            Queued::Instead | Queued::Free => "queued",
1932            Queued::Placebo => outcome::PLACEBO,
1933        };
1934        record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, outcome::ROUTE_NEVER, "session ended");
1935    }
1936}
1937
1938/// For each of `ids`, its value on the latest `prefix` line (`#borg-p `, `#borg-pf `) tagged `check`;
1939/// empty when any of them has none there (unknown, never a guess).
1940fn tagged_p(state: &str, prefix: &str, check: &str, ids: &[&str]) -> Vec<f64> {
1941    let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
1942    for line in state.lines().filter_map(|l| l.strip_prefix(prefix)) {
1943        let (items, at) = tagged(line);
1944        if at != Some(check) {
1945            continue;
1946        }
1947        for (id, p) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
1948            if let Ok(p) = p.trim().parse::<f64>() {
1949                seen.insert(id.trim(), p);
1950            }
1951        }
1952    }
1953    ids.iter().map(|i| seen.get(i).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
1954}
1955
1956/// Lessons, each with the check that routed it.
1957type Pairs<'a> = Vec<(&'a conscience::Challenge, &'a str)>;
1958
1959/// Where a lesson the queue drops at delivery came from and why: its check, channel, route (outcome.rs
1960/// `ROUTES`) and reason.
1961type Drop<'a> = (&'a str, &'static str, &'static str, &'static str);
1962
1963/// The waiting graded lessons, once (`queued_file`): the asks a check queued (`ASK_NEXT`) unless done
1964/// (`done_in`, the definition its check's pool used), handed over all and first and marked and recorded
1965/// as asks on channel live (`record_asked`), a detached check's as questions about the turn it judged
1966/// (`PROPOSAL`, conscience.rs `render_late`, that message the work before them in the .edits file, as
1967/// before a block), a live check's from before 0.7.6 with the free lessons; the free lessons not asked or handed over in
1968/// the session yet (`had_in`), those in place of a question (`INSTEAD_NEXT`, `LEGACY_INSTEAD`) all and next, the
1969/// others as many as one early delivery fits beside them (`conscience::render_owing`); and last the
1970/// placebos not shown meanwhile (asked or handed over, now or before; one the budget cut stays a placebo),
1971/// never shown but marked for the judge (`#borg-placebo`, judged as a free delivery is). Free deliveries
1972/// are marked `#borg-queued <ids> @<check>` in the state and .edits files (after the work so far, `WORK`),
1973/// so neither this queue nor early delivery repeats them, and Stop treats them as early lessons (done once
1974/// acted on); an `asked` outcome per routing check with channel queued and that check's free propensity
1975/// (`#borg-pf`, `record_delivery`). Every lesson of the queue gets its `delivery` record: asked, free,
1976/// placebo, or dropped and why.
1977fn queued(so_far: &SoFar, hook_event: &str, session: &str, input: &Value) -> Option<String> {
1978    reclaim_reserved(session);
1979    let text = take_queue(session)?;
1980    let all = lessons(session);
1981    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1982    let had = had_in(&state);
1983    let mut done: Option<std::collections::HashSet<&str>> = None;
1984    let mut lines: Vec<(Queued, Vec<&str>, &str)> = text.lines().map(queue_line).collect();
1985    // Asks, then free lessons in place of questions, then the other free ones, then placebos
1986    // (`Queued`'s order), each in its lines' order.
1987    lines.sort_by_key(|l| l.0);
1988    let placebos = lines.iter().position(|l| l.0 == Queued::Placebo).unwrap_or(lines.len());
1989    // The messages detached checks judged (`PROPOSAL`), read only when an ask waits.
1990    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() };
1991    // A detached check's asks (`late`), apart from the lessons the free delivery renders (`wanted`).
1992    let (mut wanted, mut placebo, mut late): (Pairs, Pairs, Pairs) = (Vec::new(), Vec::new(), Vec::new());
1993    let mut dropped: Vec<(Drop, &str)> = Vec::new();
1994    // The first `owed` of `wanted`: the legacy asks (its first `asks`) and the free lessons in place of
1995    // questions, which sort first.
1996    let (mut owed, mut asks) = (0, 0);
1997    for (route, ids, check) in &lines[..placebos] {
1998        let channel = if *route == Queued::Ask { "live" } else { "queued" };
1999        for &id in ids {
2000            let Some(c) = all.iter().find(|c| c.id == id) else {
2001                dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
2002                continue;
2003            };
2004            let why = if wanted.iter().chain(&late).any(|(c, _)| c.id == id) {
2005                Some("queued twice")
2006            } else if *route == Queued::Ask {
2007                done.get_or_insert_with(|| done_in(&state, &early_acted(session, &all, "", None))).contains(id).then_some("done")
2008            } else {
2009                had.contains(id).then_some("given before")
2010            };
2011            match why {
2012                Some(why) => dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, why), id)),
2013                None if *route == Queued::Ask && proposal_of(&edits, check).is_some() => late.push((c, check)),
2014                None => {
2015                    owed += usize::from(*route != Queued::Free);
2016                    asks += usize::from(*route == Queued::Ask);
2017                    wanted.push((c, check));
2018                }
2019            }
2020        }
2021    }
2022    let free: Vec<&conscience::Challenge> = wanted.iter().map(|(c, _)| *c).collect();
2023    let shown = conscience::render_owing(&free, &[], owed);
2024    let n = shown.as_ref().map_or(0, |(_, n)| *n);
2025    dropped.extend(wanted[n..].iter().map(|(c, check)| ((*check, "queued", outcome::ROUTE_DROPPED_CHARS, "budget"), c.id.as_str())));
2026    // A placebo is a lesson the drone was not shown: decided against what this delivery shows (the free
2027    // lessons within the budget) and what it was shown before.
2028    for (_, ids, check) in &lines[placebos..] {
2029        for &id in ids {
2030            let Some(c) = all.iter().find(|c| c.id == id) else {
2031                dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
2032                continue;
2033            };
2034            let why = if placebo.iter().any(|(c, _)| c.id == id) {
2035                Some("queued twice")
2036            } else {
2037                (wanted[..n].iter().chain(&late).any(|(c, _)| c.id == id) || had.contains(id)).then_some("delivered")
2038            };
2039            match why {
2040                Some(why) => dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, why), id)),
2041                None => placebo.push((c, check)),
2042            }
2043        }
2044    }
2045    let mut parts: Vec<String> = Vec::new();
2046    let mut markers: Vec<String> = Vec::new();
2047    // A detached check's asks, first and all (owed, as the block they replace), one delivery per check
2048    // naming the turn it judged; its marker pair after that turn's message in the .edits file, the work
2049    // before the questions (`pending`), so the next stop records the drone's answer (`note_answer`).
2050    for (check, ids) in by_check(&late) {
2051        let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| late.iter().find(|(c, _)| c.id == *i).map(|(c, _)| *c)).collect();
2052        let said = proposal_of(&edits, check).unwrap_or_default();
2053        let text = conscience::render_late(&members, &one_line(said, LATE_EXCERPT));
2054        mark_late(session, input, check, &members, said, hook_event, text.len());
2055        so_far.late.borrow_mut().extend(ids.iter().map(|id| lesson_wake_id(check, id)));
2056        parts.push(text);
2057    }
2058    if let Some((text, n)) = shown {
2059        // The legacy asks, all shown (owed): one write per check, `#borg-live` right after its
2060        // `#borg-asked` (`pending` reads the pair as a live ask).
2061        let now = learn::now_ms();
2062        for (check, ids) in by_check(&wanted[..asks]) {
2063            let list = ids.join(",");
2064            markers.push(format!("#borg-asked {list}{}\n#borg-live {list} ms={now}", tag(check)));
2065            let members: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
2066            record_asked(session, input, &members, "live", check);
2067        }
2068        for (check, ids) in by_check(&wanted[asks..n]) {
2069            markers.push(format!("#borg-queued {}{}", ids.join(","), tag(check)));
2070            let delivered: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
2071            // The routing check's chance of handing each over free; 1 on an older binary's line (the
2072            // fixed rule's certain hand-over).
2073            let p = if check.is_empty() { vec![1.0; ids.len()] } else { tagged_p(&state, "#borg-pf ", check, &ids) };
2074            record_delivery(session, input, &delivered, "queued", check, p, Vec::new());
2075        }
2076        record_routes(session, input, &wanted[asks..n], "queued", outcome::ROUTE_FREE, "");
2077        log_cost(session, &format!("free event={hook_event} lessons={n} chars={} graded dropped={}", text.len(), wanted.len() - n));
2078        crate::stats::count(&learn::home(), crate::stats::QUEUED_FREE, session, |e| {
2079            e.n = n as u64;
2080            e.lessons = wanted[..n].iter().map(|(c, _)| c.id.clone()).collect();
2081            e.channel = "queued".into();
2082        });
2083        parts.push(text);
2084    }
2085    // Placebos: nothing reaches the drone; the next judge call sees each as it would a free delivery.
2086    for (check, ids) in by_check(&placebo) {
2087        markers.push(format!("#borg-placebo {}{}", ids.join(","), tag(check)));
2088    }
2089    record_routes(session, input, &placebo, outcome::PLACEBO, outcome::ROUTE_PLACEBO, "");
2090    if !markers.is_empty() {
2091        let markers = markers.join("\n");
2092        append(session, &markers);
2093        note_work(session, so_far, &markers);
2094    }
2095    let mut groups: Vec<(Drop, Vec<String>)> = Vec::new();
2096    for (key, id) in dropped {
2097        match groups.iter_mut().find(|(k, _)| *k == key) {
2098            Some((_, ids)) => ids.push(id.to_string()),
2099            None => groups.push((key, vec![id.to_string()])),
2100        }
2101    }
2102    for ((check, channel, route, why), ids) in groups {
2103        record_route(session, input, check, ids, channel, route, why);
2104    }
2105    (!parts.is_empty()).then(|| parts.join("\n\n"))
2106}
2107
2108/// This turn's work so far (harness `work_so_far`): from the transcript where the harness
2109/// writes one, else the turn's tool calls (`turn_file`): no prose.
2110fn work_so_far(h: &dyn Harness, input: &Value) -> String {
2111    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2112    harness::work_so_far(h.caps(), input, &turn_file(session), EVIDENCE_CHARS)
2113}
2114
2115/// One hook's work so far (`work_so_far`), read when a delivery first needs it and then shared: the free
2116/// deliveries and placebos of one prompt or tool call write the same work line (`note_work`)
2117/// from one read of the transcript, and a hook that delivers nothing reads none.
2118struct SoFar<'a> {
2119    h: &'a dyn Harness,
2120    input: &'a Value,
2121    text: std::cell::OnceCell<String>,
2122    /// The detached checks' asks this hook delivered, as wake ids (`lesson_wake_id`): its answer's
2123    /// `shown_ids` (`free_context_with`).
2124    late: std::cell::RefCell<Vec<String>>,
2125}
2126
2127impl<'a> SoFar<'a> {
2128    fn new(h: &'a dyn Harness, input: &'a Value) -> Self {
2129        SoFar { h, input, text: std::cell::OnceCell::new(), late: std::cell::RefCell::default() }
2130    }
2131
2132    fn get(&self) -> &str {
2133        self.text.get_or_init(|| work_so_far(self.h, self.input))
2134    }
2135}
2136
2137/// This turn's tool calls, one `[tool] touch` line each, and their edit text: the work so far where the
2138/// harness sends no transcript. Emptied at each prompt.
2139fn turn_file(session: &str) -> PathBuf {
2140    state_file(session).with_extension("turn")
2141}
2142
2143/// One open queen question for the engineer (queen.rs `deliver`), at the first prompt of the session
2144/// where one of its work repos (the cwd's and the touched ones, systems.rs `work_repos`) has one: never
2145/// another repo's. Its commands run this binary in the session's `cwd`, wherever the drone's shell has
2146/// gone since. 0 model calls; fails open. Off with `BORG_QUEEN_ASK=off` or `BORG_LEARN=off` (nothing
2147/// recorded; eval drones cannot answer), and in the eval modes BORG_ONLY and BORG_CRITIC.
2148fn queen_ask(h: &dyn Harness, session: &str, cwd: &str) -> Option<String> {
2149    if !learning() || !crate::queen::ask_enabled() || std::env::var_os("BORG_ONLY").is_some() || std::env::var_os("BORG_CRITIC").is_some() {
2150        return None;
2151    }
2152    let repos = || {
2153        let mut repos = crate::systems::work_repos(&std::fs::read_to_string(where_file(session)).unwrap_or_default());
2154        let here = repo_of(cwd);
2155        if !here.is_empty() && !repos.contains(&here) {
2156            repos.insert(0, here);
2157        }
2158        repos
2159    };
2160    let scope = if cwd.is_empty() { String::new() } else { format!(" --dir {}", crate::queen::sh_quote(cwd)) };
2161    let mut q = crate::queen::deliver(&learn::home(), session, repos, &scope, learn::now_ms())?;
2162    if let Ok(exe) = std::env::current_exe() {
2163        q = q.run_by(&crate::queen::sh_quote(&exe.display().to_string()));
2164    }
2165    let text = crate::queen::render_delivery(&q, h.caps().question_dialog);
2166    log_cost(session, &format!("free event=UserPromptSubmit queen={} chars={}", q.id, text.len()));
2167    Some(text)
2168}
2169
2170/// The free context of a prompt or touch: waiting graded lessons first, then an early delivery.
2171fn free_context(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<Value> {
2172    free_context_with(h, hook_event, session, input, text, None)
2173}
2174
2175/// `free_context` plus `extra` (a queen question) last.
2176/// The answer names the detached checks' asks it delivered in `"shown_ids"` (`lesson_wake_id`), as it
2177/// names the messages it shows (msg.rs `deliver`): an adapter drops a wake it holds for them
2178/// (listen.rs), and `for_harness` strips the key where no adapter reads it.
2179fn free_context_with(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str, extra: Option<String>) -> Option<Value> {
2180    let so_far = SoFar::new(h, input);
2181    let parts: Vec<String> =
2182        [queued(&so_far, hook_event, session, input), early(h, &so_far, hook_event, session, input, text), extra].into_iter().flatten().collect();
2183    let mut out = (!parts.is_empty()).then(|| json!({"hookSpecificOutput": {"hookEventName": hook_event, "additionalContext": parts.join("\n\n")}}))?;
2184    let late = so_far.late.take();
2185    if !late.is_empty() {
2186        out["shown_ids"] = json!(late);
2187    }
2188    Some(out)
2189}
2190
2191/// What one model check of the work decided (`check`).
2192enum Verdict {
2193    /// The free gate skipped the model call: the score, or `noedit`.
2194    Skip(String),
2195    /// The model gave no answer: fail open.
2196    Failed,
2197    /// The ids to ask, in a block at the stop.
2198    Ask(Vec<String>),
2199}
2200
2201/// The work a check judges, as the stop reads it.
2202struct Work<'a> {
2203    /// Every lesson of the session it may deliver (the ungraded widening looks here).
2204    all: &'a [conscience::Challenge],
2205    /// `all` and the absorbed lessons withheld from it (`judged`): the judge's pending lessons look here,
2206    /// so a delivery made before an absorb still gets its verdict.
2207    judged: &'a [conscience::Challenge],
2208    /// The lessons that may be asked: not done (`done_in`), not decided already (waiting in the queue to
2209    /// be asked or handed over, `waiting_in`), not of another system.
2210    challenges: &'a [conscience::Challenge],
2211    done: &'a std::collections::HashSet<&'a str>,
2212    /// The state file as read before the check.
2213    state: &'a str,
2214    /// The state file plus the drone's work text: the gate finds the turn in it by its markers; the pool
2215    /// and the model read it without them (`conscience::work_text`).
2216    context: &'a str,
2217    scope_text: &'a str,
2218    /// The drone's message for the judge (`pending`): its final message at the stop.
2219    message: &'a str,
2220    /// BORG_STOP_GATE=edits: whether the turn changed anything.
2221    changed: bool,
2222    /// Whether this check judges the pending lessons: not while another (the session-end judge, holding
2223    /// the session lock) may be judging the same ones.
2224    judge: bool,
2225    /// The harness's stop budget (none: the model's own limit) and when the hook started: the model call
2226    /// gets what is left of it when it starts (live.rs `call_limit`).
2227    budget: Option<std::time::Duration>,
2228    started: std::time::Instant,
2229    /// A detached check's own limit for its model call (check_async.rs `call_limit`), in place of the
2230    /// stop's (`budget`); it decides on channel live and queues its asks for the next prompt
2231    /// (`settle`). None at the stop.
2232    detached: Option<std::time::Duration>,
2233    /// A detached check's .edits length at its stop (check_async.rs `Job::edits`): what its judge reads
2234    /// (`pending_upto`). None at the stop: all of it.
2235    edits: Option<usize>,
2236    /// This check's id (select.rs `check_id`), computed once when it started: on its `graded` record and
2237    /// on every marker of what it routed.
2238    check: &'a str,
2239    /// The selection summary beside the lessons file, read at the check's start (select/summary.rs
2240    /// `refresh`): what sizes the pool before the model call, and what a decision by a policy that reads
2241    /// no gate reads; a learned decision reads it again, under the store's decision lock (`decide`).
2242    summary: &'a crate::select::summary::Summary,
2243}
2244
2245impl Work<'_> {
2246    /// The model call's limit when it starts: a detached check's own, else what the stop's budget leaves
2247    /// (live.rs `call_limit`).
2248    fn limit(&self) -> std::time::Duration {
2249        self.detached.unwrap_or_else(|| live::call_limit(self.budget, self.started.elapsed()))
2250    }
2251
2252    /// The channel the check decides on: `stop`, or `live` for a detached check, whose asks reach the
2253    /// drone at its next prompt or tool call (select/summary.rs reads `live` as asked, no block).
2254    fn channel(&self) -> &'static str {
2255        if self.detached.is_some() { "live" } else { "stop" }
2256    }
2257}
2258
2259/// What a check's `graded` record says of its pool and its model call (`record_graded`).
2260struct Call<'a> {
2261    /// Lessons the pool allowed, and those shown in the order they filled its budget (whole.rs
2262    /// `select`; the shortlist's order without the whole pool).
2263    eligible: usize,
2264    order: &'a [&'a conscience::Challenge],
2265    /// The tail sample shown after the pool (whole.rs `tail`): each lesson with its place in the full
2266    /// fill order (`order.len()` + its place among the lessons the budget dropped), and each one's
2267    /// chance of the draw (0 without a tail).
2268    tail: &'a [(usize, &'a conscience::Challenge)],
2269    pt: f64,
2270    /// The boundary draw shown after the pool (whole.rs `explore`), each lesson with its place in the
2271    /// full fill order: an apply's fill position, never a tail lesson's or one the summary folds.
2272    boundary: &'a [(usize, &'a conscience::Challenge)],
2273    /// The pool's token budget the check filled to (0 without the whole pool), outcome.rs `budget`.
2274    budget: usize,
2275    model: &'a str,
2276    prompt_hash: String,
2277    dur_ms: u64,
2278    /// The call's cost as the CLI reported it (0 for a failed call).
2279    usd: f64,
2280    /// The compact prompt's lesson line format (whole.rs `line_format`), empty without it; the answer's
2281    /// design moves, and per apply its why (aligned with the record's applies): outcome.rs `line`,
2282    /// `moves`, `whys`.
2283    line: &'static str,
2284    moves: Vec<String>,
2285    whys: Vec<String>,
2286}
2287
2288/// The consequence model's check of `w` at the stop: the free gate (`gate`), the pool (with its tail
2289/// sample, whole.rs `tail`; `pool`), one graded call that also judges the pending lessons (`call`), and
2290/// the graded split (select.rs `graded_under`), decided and recorded as one step (`settle`, `decide`:
2291/// under the store's decision lock for the learned policy, which carries out a decision only once its
2292/// record landed where its gate is folded from). Its decision is logged before anything carries it out
2293/// (the `graded` record, then `#borg-graded`, `#borg-select`, `#borg-p`, `#borg-pf` in one write, then
2294/// the queue): a hook killed in between asks nothing and leaves no marker of an unlogged decision. The
2295/// free lessons and the placebos wait in the `.queued` file; what to ask is returned. Logged as `check`.
2296fn check(session: &str, input: &Value, w: &Work) -> Verdict {
2297    if let Some(score) = gate(w.challenges, w.context, w.changed) {
2298        return Verdict::Skip(score);
2299    }
2300    // What the pool and the model read: the work, without the state file's bookkeeping lines.
2301    let text = conscience::work_text(w.context);
2302    let pool = match pool(session, w, &text) {
2303        Ok(pool) => pool,
2304        Err(skip) => return skip,
2305    };
2306    let called = call(session, input, w, &pool, &text);
2307    settle(session, input, w, pool, called, None).verdict
2308}
2309
2310/// The free gate (prefilter.rs): a turn whose words touch none of `challenges` costs no model call; the
2311/// conscience then asked nothing. BORG_STOP_GATE=edits instead: a turn that changed nothing costs no
2312/// model call. Why it skips (the score, or `noedit`); None when the model is asked.
2313fn gate(challenges: &[conscience::Challenge], context: &str, changed: bool) -> Option<String> {
2314    if prefilter::edits_gate() { (!changed).then(|| "noedit".to_string()) } else { prefilter::skip(challenges, context).map(|score| format!("{score:.2}")) }
2315}
2316
2317/// What a check shows its model (`pool`).
2318struct Pool<'a> {
2319    /// The lessons shown: the pool's, then the boundary's and the tail sample's.
2320    shortlist: Vec<&'a conscience::Challenge>,
2321    /// `Call`'s fields: the lessons the pool allowed, those shown in fill order, the tail sample and
2322    /// its chance, the boundary draw, the pool's token budget.
2323    eligible: usize,
2324    order: Vec<&'a conscience::Challenge>,
2325    tail: Vec<(usize, &'a conscience::Challenge)>,
2326    pt: f64,
2327    boundary: Vec<(usize, &'a conscience::Challenge)>,
2328    budget: usize,
2329    /// The whole pool (whole.rs `on`), not the shortlist.
2330    whole: bool,
2331    /// The pending lessons the call judges (`pending`).
2332    judging: Judging<'a>,
2333    /// What a detached check logs of its pool beside its record (pools.rs), None without the whole
2334    /// pool; and the work text the pool read, which its shadow scores read again after the call.
2335    rows: Option<crate::pools::Rows<'a>>,
2336    text: String,
2337}
2338
2339/// The pool of `w` over its work `text` (`Pool`); `Err` with the skip when there is nothing to ask and
2340/// no question to judge.
2341fn pool<'a>(session: &str, w: &Work<'a>, text: &str) -> Result<Pool<'a>, Verdict> {
2342    // BM25's top 30, plus lessons the vector index finds that word matching missed (vectors.rs; here only,
2343    // where the model call dwarfs loading the embedder). Top 30 found as much as top 20 + vectors; both
2344    // together find the most (internal evaluation "vectors", not published).
2345    let started = std::time::Instant::now();
2346    let shortlist_now = || {
2347        let (list, added) = crate::vectors::shortlist(&lessons_path(), w.challenges, text, w.scope_text, crate::vectors::SHORTLIST);
2348        if crate::vectors::enabled() {
2349            log_cost(session, &format!("vectors added={added} ms={}", started.elapsed().as_millis()));
2350        }
2351        list
2352    };
2353    // The whole pool (whole.rs, default): every lesson the Stop allows, up to a token budget; the
2354    // shortlist's order decides only when they do not all fit. BORG_STOP_FORMAT=classic asks about that
2355    // pool in the shortlist's layout (predict::prompt).
2356    let whole = crate::whole::on();
2357    let (shortlist, eligible, order, tail, pt, boundary, budget, rows) = if whole {
2358        let raw = conscience::raw(w.challenges, text);
2359        let ranked = conscience::rank_raw(w.challenges, raw.clone(), w.scope_text);
2360        let eligible = crate::whole::eligible(w.challenges, &ranked, w.scope_text);
2361        let n = eligible.len();
2362        // The pool's budget: BORG_STOP_POOL_TOKENS when set, else the policy's (select.rs `pool_tokens`:
2363        // today's `whole::TOKENS` unless the learned policy's gate may risk a larger one that fits the
2364        // model call's limit).
2365        let tokens = match std::env::var_os("BORG_STOP_POOL_TOKENS") {
2366            Some(_) => crate::whole::tokens(),
2367            None => {
2368                let limit = w.limit();
2369                crate::select::pool_tokens(w.summary, Some(u64::try_from(limit.as_millis()).unwrap_or(u64::MAX)), session)
2370            }
2371        };
2372        // Exploration (whole.rs `explore`, plan W3b "Pool"), under every policy: a pool the budget cuts
2373        // fills its last share by a draw at the boundary of the fill order, and shows a uniform tail
2374        // sample of the lessons the budget dropped after it (its stable prefix stays), so the yield of
2375        // fill positions past the budget is measured; each lesson's chance is known (pools.rs).
2376        let e = crate::whole::explore(eligible, shortlist_now, &ranked, tokens, crate::whole::boundary_share(), tail_seed(session, w.check, w.summary));
2377        let sampled = if e.tail.is_empty() { String::new() } else { format!(" tail={}", e.tail.len()) };
2378        log_cost(
2379            session,
2380            &format!(
2381                "pool mode=all eligible={n} shown={} budget={}{sampled} boundary={}",
2382                e.filled.shown.len(),
2383                if e.filled.fell { "full" } else { "fits" },
2384                e.boundary.len()
2385            ),
2386        );
2387        // A detached check logs its pool (pools.rs); a stop's own check (BORG_ASYNC=off) adds nothing.
2388        let rows = w.detached.map(|_| crate::pools::Rows::of(&e, w.challenges, &raw, tokens));
2389        let mut shown = e.filled.shown.clone();
2390        shown.extend(e.boundary.iter().chain(&e.tail).map(|(_, c)| *c));
2391        let crate::whole::Explored { filled, tail, pt, boundary, .. } = e;
2392        (shown, n, filled.order, tail, pt, boundary, tokens, rows)
2393    } else {
2394        let list = shortlist_now();
2395        (list.clone(), list.len(), list, Vec::new(), 0.0, Vec::new(), 0, None)
2396    };
2397    // R1: the same call judges what the drone did with lessons delivered earlier this session.
2398    let judging = if w.judge {
2399        pending_upto(session, w.state, w.judged, w.message, When::Stop, w.check, w.edits)
2400    } else {
2401        Judging { pending: Vec::new(), question: false, crowded: Vec::new() }
2402    };
2403    // Nothing to ask and no question to judge (no lesson in the pool, none pending): no model call.
2404    // Free deliveries alone never pay for a call: they wait for the next one, or `borg learn` settles them.
2405    if shortlist.is_empty() && !judging.question {
2406        return Err(Verdict::Skip("empty".into()));
2407    }
2408    Ok(Pool { shortlist, eligible, order, tail, pt, boundary, budget, whole, judging, rows, text: text.to_string() })
2409}
2410
2411/// A check's model call (`call`): the answer (None: failed), how long it took, and how it was asked.
2412struct Called {
2413    pred: Option<predict::Prediction>,
2414    dur_ms: u64,
2415    model: String,
2416    /// The judge section's characters (`predict::judge_section`), the cost line's `chars=`.
2417    extra: usize,
2418    graded: bool,
2419    compact: bool,
2420}
2421
2422/// The model call of `w` over `pool` and its work `text`, within `Work::limit`; counted as a `check`
2423/// (`note_check`; a detached check's once it is done).
2424fn call(session: &str, input: &Value, w: &Work, pool: &Pool, text: &str) -> Called {
2425    let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
2426    let pend = &pool.judging.pending;
2427    let extra = predict::judge_section(pend).len();
2428    let graded = crate::whole::graded();
2429    let compact = pool.whole && !crate::whole::classic();
2430    let timeout = w.limit();
2431    // The work the call shows holds no stripped question's declaration either: this turn's message may
2432    // be the drone's answer to it.
2433    let shown_work = without_declarations(text, pend);
2434    let called = std::time::Instant::now();
2435    let pred = if compact {
2436        predict::predict_whole(&shown_work, &pool.shortlist, pend, &model, graded, timeout)
2437    } else {
2438        predict::predict(&shown_work, &pool.shortlist, pend, &model, graded, timeout)
2439    };
2440    let dur_ms = called.elapsed().as_millis() as u64;
2441    // A detached check's comes when it is done (`run_job`), with the peak memory of all it did.
2442    if w.detached.is_none() {
2443        note_check(session, input, pool.shortlist.len(), dur_ms, pred.as_ref().map(|p| p.cost), None);
2444    }
2445    Called { pred, dur_ms, model, extra, graded, compact }
2446}
2447
2448/// The `check` event of a model call: the lessons shown, its duration, its cost (None: it failed); a
2449/// detached check's (`limit`, its call's limit) in mode `async` with the process's peak memory.
2450fn note_check(session: &str, input: &Value, shown: usize, dur_ms: u64, cost: Option<f64>, limit: Option<std::time::Duration>) {
2451    stat(session, input, crate::stats::CHECK, |e| {
2452        e.n = shown as u64;
2453        e.dur_ms = dur_ms;
2454        e.usd = cost.unwrap_or(0.0);
2455        if cost.is_none() {
2456            e.why = "failed".into();
2457        }
2458        if let Some(limit) = limit {
2459            e.mode = crate::check_async::MODE.into();
2460            e.rss_kb = crate::check_async::rss_kb();
2461            e.limit_ms = u64::try_from(limit.as_millis()).unwrap_or(u64::MAX);
2462        }
2463    });
2464}
2465
2466/// What a detached check (check_async.rs) carries beside a stop's check when it writes its answer, under
2467/// the session lock: the state file's length at its stop (`offset`), whether the session ended since
2468/// (`gone`), and the drone's message it judged (`proposal`).
2469pub(crate) struct Late<'l> {
2470    pub offset: usize,
2471    pub gone: bool,
2472    pub proposal: &'l str,
2473    /// When its stop ran: what its pool log (pools.rs) reads of the store is what was there before.
2474    pub stop_ms: i64,
2475}
2476
2477/// What a check carried out (`settle`): its verdict (a detached check's asks are queued already), the
2478/// lessons it handed over free, the waiting asks of earlier checks it took off the queue
2479/// (`supersede`), and a detached check's pool to log once it let go of the session lock (`PoolLog`).
2480struct Settled<'a> {
2481    verdict: Verdict,
2482    free: Vec<String>,
2483    superseded: Vec<String>,
2484    pool: Option<PoolLog<'a>>,
2485}
2486
2487/// A detached check's pool and what its call made of it, for its line of the pool log (pools.rs),
2488/// scored after the check wrote its answer and let go of the session lock (`run_job`, `log_pool`).
2489struct PoolLog<'a> {
2490    rows: crate::pools::Rows<'a>,
2491    text: String,
2492    applied: Vec<(&'a conscience::Challenge, u8)>,
2493    pending: Vec<String>,
2494    line: &'static str,
2495    model: String,
2496    prompt_hash: String,
2497    failed: bool,
2498    /// Its index among the session's checks, read before its own is counted (check_async.rs `index`).
2499    idx: u64,
2500    stop_ms: i64,
2501}
2502
2503/// `p`'s line of the pool log (pools.rs `log`) for the check of `w`: the lessons the session had and
2504/// those in play read from its state at the check.
2505fn log_pool(session: &str, w: &Work, p: &PoolLog) {
2506    let delivered = had_in(w.state);
2507    let in_play: std::collections::HashSet<&str> = delivered.iter().copied().chain(applied_in(w.state)).collect();
2508    let c = crate::pools::Check {
2509        session,
2510        check: w.check,
2511        idx: p.idx,
2512        stop_ms: p.stop_ms,
2513        line: p.line,
2514        model: &p.model,
2515        prompt_hash: &p.prompt_hash,
2516        failed: p.failed,
2517        applied: &p.applied,
2518        pending: &p.pending,
2519        text: &p.text,
2520        scope_text: w.scope_text,
2521        challenges: w.challenges,
2522        delivered: &delivered,
2523        in_play: &in_play,
2524        lessons: &lessons_path(),
2525        outcomes: &graded_log(),
2526    };
2527    crate::pools::log(&learn::home(), &p.rows, &c);
2528}
2529
2530/// `w`'s answer `called` over `pool`, recorded and carried out (`check`). A detached check (`late`)
2531/// records no verdict on a lesson settled or delivered again since its stop (`touched_since`): the
2532/// session-end judge's, or a later delivery's, stands. Its asks wait for the next prompt or tool call
2533/// (`ASK_NEXT`, its judged message beside them in the .edits file, `PROPOSAL`), after it took the asks
2534/// of earlier checks it showed its model again off the queue (`supersede`); when the session ended
2535/// during its call nothing waits, and each routed lesson's `delivery` record says so.
2536fn settle<'a>(session: &str, input: &Value, w: &Work<'a>, pool: Pool<'a>, called: Called, late: Option<&Late>) -> Settled<'a> {
2537    let Pool { shortlist, eligible, order, tail, pt, boundary, budget, judging, rows, text, .. } = pool;
2538    let Called { pred, dur_ms, model, extra, graded, compact } = called;
2539    let none = |verdict| Settled { verdict, free: Vec::new(), superseded: Vec::new(), pool: None };
2540    let usd = pred.as_ref().map_or(0.0, |p| p.cost);
2541    let line = if compact { crate::whole::line_format().name() } else { "" };
2542    let mut call = Call {
2543        eligible,
2544        order: &order,
2545        tail: &tail,
2546        pt,
2547        boundary: &boundary,
2548        budget,
2549        model: &model,
2550        prompt_hash: predict::template_hash(compact, graded),
2551        dur_ms,
2552        usd,
2553        line,
2554        moves: Vec::new(),
2555        whys: Vec::new(),
2556    };
2557    note_unjudged(session, &judging.crowded, "slots");
2558    let mut pend = judging.pending;
2559    if let Some(late) = late {
2560        let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2561        let since = touched_since(state.get(late.offset..).unwrap_or_default());
2562        pend.retain(|p| !since.contains(p.lesson.id.as_str()));
2563    }
2564    // A detached check's pool, with the applies' grades, for its line of the pool log (`PoolLog`).
2565    let pending: Vec<String> = pend.iter().map(|p| p.lesson.id.clone()).collect();
2566    let pool_log = |applied: &[(&'a conscience::Challenge, u8)], failed: bool, prompt_hash: &str| {
2567        let (rows, late) = (rows?, late?);
2568        Some(PoolLog {
2569            rows,
2570            text,
2571            applied: applied.to_vec(),
2572            pending,
2573            line,
2574            model: model.clone(),
2575            prompt_hash: prompt_hash.to_string(),
2576            failed,
2577            idx: crate::check_async::index(session),
2578            stop_ms: late.stop_ms,
2579        })
2580    };
2581    let Some(pred) = pred else {
2582        // Fail open: nothing asked, the pending lessons stay pending (no try counted).
2583        if graded {
2584            record_graded(session, input, w, &[], None, &call);
2585        }
2586        note_unjudged(session, &pend.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>(), "failed");
2587        let pool = if graded { pool_log(&[], true, &call.prompt_hash) } else { None };
2588        return Settled { pool, ..none(Verdict::Failed) };
2589    };
2590    // The judge's call numbering counts every model call of the session.
2591    append(session, &format!("#borg-predict-cost {:.4}", pred.cost));
2592    // The session ended during a detached check's call: the session-end judge has its pending lessons
2593    // (and `borg learn` may have settled them), so no verdict of this call lands after that.
2594    let gone = late.is_some_and(|l| l.gone);
2595    if !pend.is_empty() && !gone {
2596        // A detached check counts in the state file as it is now: a check that wrote after its stop
2597        // gets its own number.
2598        let call = match late {
2599            Some(_) => std::fs::read_to_string(state_file(session)).unwrap_or_default().lines().filter(|l| l.starts_with("#borg-predict-cost")).count(),
2600            None => w.state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1,
2601        };
2602        // The whole prompt's characters (`prompt=`): the judge's effective length is measured by it (judge_length.rs).
2603        let logged = Logged { call, chars: extra, model: judge_model(&model), fields: format!(" prompt={}", pred.prompt) };
2604        record_verdicts(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()), "", &pend, &pred, &logged);
2605    }
2606    // The applies decide; a why each, empty where the answer gave none.
2607    let (applied, whys): (Vec<(&conscience::Challenge, u8)>, Vec<String>) = pred
2608        .applies
2609        .iter()
2610        .zip(&pred.grades)
2611        .enumerate()
2612        .filter_map(|(at, (i, g))| shortlist.iter().find(|c| &c.id == i).map(|c| ((*c, *g), pred.whys.get(at).cloned().unwrap_or_default())))
2613        .unzip();
2614    call.moves.clone_from(&pred.moves);
2615    call.whys = whys;
2616    let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
2617    if graded {
2618        // Graded (default): select.rs decides what of the applies to ask, hand over free at the next
2619        // prompt or touch, or judge as a placebo (the fixed rule: ask only for a 3, else the 2s and 1s
2620        // free, conscience.rs `graded_without` with the session's `had`).
2621        let lessons = lessons_path();
2622        // Decided at the pool tokens the record logs, which the fit and the gate's ledger read; and, under
2623        // the learned policy, with the store's other checks one at a time (`decide`).
2624        let at = Decide {
2625            session,
2626            check: w.check,
2627            channel: w.channel(),
2628            lessons: &lessons,
2629            tokens: crate::select::shown_tokens(call.order),
2630            recorded: records_reach_gate(&lessons),
2631        };
2632        let r = decide(crate::select::policy(), &applied, &at, w.summary, DECIDE_WAIT, |r, basis| {
2633            record_graded(session, input, w, &applied, Some((r, basis)), &call)
2634        });
2635        let pool = pool_log(&applied, false, &call.prompt_hash);
2636        let routed = |cs: &[&conscience::Challenge], p: &[f64]| -> Vec<(&str, f64)> {
2637            applied.iter().zip(p).filter(|((c, _), _)| cs.iter().any(|x| x.id == c.id)).map(|((c, _), p)| (c.id.as_str(), *p)).collect()
2638        };
2639        let mut lines = vec![graded_line(&applied), r.log.line(applied.len(), w.check)];
2640        lines.extend(r.log.attention.clone());
2641        lines.extend(p_line("#borg-p", routed(&r.ask, &r.p), Some(w.check)));
2642        lines.extend(p_line("#borg-pf", routed(&r.free, &r.pf), Some(w.check)));
2643        append(session, &lines.join("\n"));
2644        let superseded = if late.is_some() && !gone { supersede(session, input, &shortlist, &r.ask) } else { Vec::new() };
2645        // What it queues, in queue order: its asks (a detached check's), the free lessons, the placebos.
2646        let mut queue: Vec<String> = Vec::new();
2647        if let Some(late) = late
2648            && !r.ask.is_empty()
2649        {
2650            // Its asks, with the message it judged (the turn their delivery names), first in the queue.
2651            note_proposal(session, w.check, late.proposal);
2652            queue.push(format!("{}{}{}", ASK_NEXT[0], ids(&r.ask).join(","), tag(w.check)));
2653        }
2654        if !r.free.is_empty() {
2655            // The learned policy's hand-overs of lessons the fixed rule asks (on a check with a 3: the
2656            // evidence rule's) are marked (`INSTEAD_NEXT`), so the free delivery's budget never cuts what
2657            // a question would have carried.
2658            let asks = if r.log.policy == crate::select::Policy::Learned.name() { conscience::graded_without(&applied, None, &[]).0 } else { Vec::new() };
2659            let (owed, rest): (Vec<&conscience::Challenge>, Vec<&conscience::Challenge>) = r.free.iter().partition(|c| asks.iter().any(|a| a.id == c.id));
2660            if !owed.is_empty() {
2661                queue.push(format!("{INSTEAD_NEXT}{}{}", ids(&owed).join(","), tag(w.check)));
2662            }
2663            if !rest.is_empty() {
2664                queue.push(format!("{}{}", ids(&rest).join(","), tag(w.check)));
2665            }
2666        }
2667        if !r.placebo.is_empty() {
2668            queue.push(format!("placebo {}{}", ids(&r.placebo).join(","), tag(w.check)));
2669        }
2670        // A detached check's lines land only while the session lives, checked under the queue's lock
2671        // the end hook reads it under (`note_never_delivered`): what it queued is either read there as
2672        // never delivered, or never queued and dropped here.
2673        let landed = !gone && enqueue_all(session, &queue, late.map(|l| l.offset));
2674        log_cost(
2675            session,
2676            &format!(
2677                "graded applied={} ask={} free={} {}",
2678                applied.len(),
2679                r.ask.len(),
2680                r.free.len(),
2681                if landed && !r.free.is_empty() { "queued" } else { "dropped" }
2682            ),
2683        );
2684        if !landed {
2685            // The session ended during the call: no next prompt comes for what it routed.
2686            for (routed, channel) in [(&r.ask, "live"), (&r.free, "queued"), (&r.placebo, outcome::PLACEBO)] {
2687                record_route(session, input, w.check, ids(routed), channel, outcome::ROUTE_DROPPED_DONE, "session ended");
2688            }
2689            return Settled { pool, superseded, ..none(Verdict::Ask(Vec::new())) };
2690        }
2691        Settled { verdict: Verdict::Ask(ids(&r.ask)), free: ids(&r.free), superseded, pool }
2692    } else {
2693        // R3b: the model decides whether to ask (at the stop, an extra round); once it does, the questions
2694        // also carry every other lesson over the low bar (conscience.rs `widen`).
2695        let predicted: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
2696        let text = conscience::work_text(w.context);
2697        let pool = conscience::widen_pool(&conscience::rank_scoped(w.all, &text, w.scope_text), &predicted, w.done);
2698        let widened: Vec<&conscience::Challenge> =
2699            conscience::in_scope_first(conscience::explored(pool.iter().copied())).into_iter().filter(|c| w.challenges.iter().any(|x| x.id == c.id)).collect();
2700        append_p_of(session, &pool, &widened, w.check);
2701        // Ungraded, the stop asks whatever it widens to: so it would have asked these.
2702        none(Verdict::Ask(ids(&widened)))
2703    }
2704}
2705
2706/// The lessons the state lines `after` (past a detached check's stop) name in a verdict
2707/// (`#borg-judged`, `END_JUDGED`) or a delivery (`DELIVERED`): settled or delivered again since, so the
2708/// check records no verdict on their earlier delivery (`settle`).
2709fn touched_since(after: &str) -> std::collections::HashSet<&str> {
2710    let mut out = std::collections::HashSet::new();
2711    for l in after.lines() {
2712        if let Some(rest) = l.strip_prefix("#borg-judged ") {
2713            out.insert(rest.split_once('=').map_or(rest, |(id, _)| id));
2714        } else if let Some(list) = l.strip_prefix(END_JUDGED).or_else(|| DELIVERED.iter().find_map(|m| l.strip_prefix(m))) {
2715            out.extend(ids_in(list));
2716        }
2717    }
2718    out
2719}
2720
2721/// Where a graded check decides (`decide`): the parts of its select.rs `Ctx` the summary does not hold.
2722struct Decide<'x> {
2723    session: &'x str,
2724    check: &'x str,
2725    channel: &'static str,
2726    lessons: &'x std::path::Path,
2727    /// The check's pool tokens (select.rs `Ctx::tokens`).
2728    tokens: u32,
2729    /// Whether the check's `graded` record lands in the log its gate is folded from
2730    /// (`records_reach_gate`): a learned decision whose record would not is never debited.
2731    recorded: bool,
2732}
2733
2734/// What a graded check's decision read (`decide`), for its record: the selection summary, the lessons
2735/// the session already had or has waiting in its queue (select.rs `Ctx::had`), and why it fell back to
2736/// the fixed rule (outcome.rs `DECIDED_BUSY`, `DECIDED_NO_LOCK`, `DECIDED_NO_RECORD`; empty when it
2737/// decided by the policy in force).
2738struct Basis<'s> {
2739    summary: &'s crate::select::summary::Summary,
2740    had: &'s [String],
2741    why: &'static str,
2742}
2743
2744/// How long a learned check waits for the store's decision lock (`decide`) at most; past it, the check
2745/// takes the fixed rule and its record says why. Guard: a check that holds the lock long (a summary
2746/// rebuilt from a long log, a stopped process) would otherwise hold up every other learned check's
2747/// answer. Derived: a third of
2748/// what the stop's model call leaves for the hook's records and answer (live.rs `AFTER_CALL`), so the
2749/// wait never costs a stop its answer.
2750const DECIDE_WAIT: std::time::Duration = std::time::Duration::from_millis(live::AFTER_CALL.as_millis() as u64 / 3);
2751
2752/// Method: how often a check waiting for the decision lock tries it again (`lock_within`).
2753const DECIDE_POLL: std::time::Duration = std::time::Duration::from_millis(5);
2754
2755/// The store's decision lock (`decide`), beside the selection summary it guards.
2756fn decide_lock(lessons: &std::path::Path) -> PathBuf {
2757    crate::select::summary::cache_path(lessons).with_extension("json.lock")
2758}
2759
2760/// `file` locked exclusively, tried every `DECIDE_POLL` for at most `wait` (once for none); unlocked when
2761/// the handle drops. Err with why when it stays held (`DECIDED_BUSY`) or cannot be taken at all
2762/// (`DECIDED_NO_LOCK`).
2763fn lock_within(file: &std::path::Path, wait: std::time::Duration) -> Result<std::fs::File, &'static str> {
2764    let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file).map_err(|_| outcome::DECIDED_NO_LOCK)?;
2765    let until = std::time::Instant::now() + wait;
2766    loop {
2767        match lock.try_lock() {
2768            Ok(()) => return Ok(lock),
2769            Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < until => std::thread::sleep(DECIDE_POLL),
2770            Err(std::fs::TryLockError::WouldBlock) => return Err(outcome::DECIDED_BUSY),
2771            Err(std::fs::TryLockError::Error(_)) => return Err(outcome::DECIDED_NO_LOCK),
2772        }
2773    }
2774}
2775
2776/// A graded check's decision and its record (`record`, which answers whether the record landed) as one
2777/// step. Only the learned policy reads the gate (select/policy.rs `choose`): its checks decide one at a
2778/// time under the store's decision lock (`decide_lock`), never during the model call, since learned
2779/// checks that decide at once (the checks of several sessions on one store) would each take all the gate
2780/// may still risk. Under the lock the summary is loaded again
2781/// (select/summary.rs `refresh`: the log's tail since the check started, other checks' records and their
2782/// ledger entries included), decided on (select.rs `graded_under`) and recorded with that summary as the
2783/// one the decision read (outcome.rs `seen`); then released. A learned decision is carried out only once
2784/// its record landed in the log its gate is folded from, the gate's only debit: one whose record would
2785/// not land there (`Decide::recorded`) or did not (`record`'s answer) takes the fixed rule instead
2786/// (`DECIDED_NO_RECORD`), as one that cannot take the lock within `wait` does (on `stale`, the summary
2787/// it read at its start), and its record says why (`Basis::why`). The fixed rule and the evaluation
2788/// switches read no gate: they decide by themselves on `stale`, without the lock, whatever other checks
2789/// hold. Every way, the session's lessons delivered by now (`had_in`, read again: a tool call may have
2790/// handed one over during the model call) or waiting in its queue (`waiting_in`) never go free.
2791fn decide<'a>(
2792    policy: crate::select::Policy,
2793    applied: &[(&'a conscience::Challenge, u8)],
2794    at: &Decide,
2795    stale: &crate::select::summary::Summary,
2796    wait: std::time::Duration,
2797    mut record: impl FnMut(&crate::select::Routed<'a>, &Basis) -> bool,
2798) -> crate::select::Routed<'a> {
2799    use crate::select::Policy;
2800    let state = std::fs::read_to_string(state_file(at.session)).unwrap_or_default();
2801    let queue = std::fs::read_to_string(queued_file(at.session)).unwrap_or_default();
2802    let reserved = std::fs::read_to_string(reserved_file(at.session)).unwrap_or_default();
2803    // A detached check (channel live) grades the waiting asks again (`supersede`); one a wake reserved is
2804    // on its way (`reserve_asks`).
2805    let mut had: Vec<String> =
2806        had_in(&state).into_iter().chain(waiting_in(&queue, at.channel == "live")).chain(waiting_in(&reserved, false)).map(str::to_string).collect();
2807    had.sort_unstable();
2808    had.dedup();
2809    // The collective's tolerance, read once: every way below decides and logs under it (select.rs `Ctx::alpha`).
2810    let alpha = crate::select::alpha(at.lessons);
2811    // Decided under `policy` on `summary` and recorded with `why`; whether the record landed.
2812    let mut on = |summary: &crate::select::summary::Summary, policy: Policy, why: &'static str| {
2813        let ctx =
2814            crate::select::Ctx { session: at.session, check: at.check, channel: at.channel, lessons: at.lessons, summary, tokens: at.tokens, had: &had, alpha };
2815        let r = crate::select::graded_under(policy, applied, &ctx);
2816        let landed = record(&r, &Basis { summary, had: &had, why });
2817        (r, landed)
2818    };
2819    if policy != Policy::Learned {
2820        return on(stale, policy, "").0;
2821    }
2822    if !at.recorded {
2823        return on(stale, Policy::Fixed, outcome::DECIDED_NO_RECORD).0;
2824    }
2825    match lock_within(&decide_lock(at.lessons), wait) {
2826        Ok(_held) => {
2827            let summary = crate::select::summary::refresh(at.lessons);
2828            match on(&summary, policy, "") {
2829                (r, true) => r,
2830                // Still under the lock: no other check decided on the gate this one left as it was.
2831                (_, false) => on(&summary, Policy::Fixed, outcome::DECIDED_NO_RECORD).0,
2832            }
2833        }
2834        Err(why) => on(stale, Policy::Fixed, why).0,
2835    }
2836}
2837
2838/// Whether a check's `graded` record (`record_graded`) lands in the log its gate is folded from, the
2839/// outcomes beside `lessons` (select/summary.rs `refresh`): learning on, and `lessons` in the store the
2840/// hook records to. A `BORG_CONSCIENCE` elsewhere reads the gate of its own store, which no record of
2841/// this hook reaches.
2842fn records_reach_gate(lessons: &std::path::Path) -> bool {
2843    learning() && outcome::path_beside(lessons) == graded_log()
2844}
2845
2846/// Where a check's `graded` record goes: beside the store's other outcomes.
2847fn graded_log() -> PathBuf {
2848    learn::home().join("outcomes.jsonl")
2849}
2850
2851/// The seed of a check's tail sample (whole.rs `tail`): its own stream of the check's seed (select.rs
2852/// `seed`, which reads only the session, channel and check; the tag spells "tailpool").
2853fn tail_seed(session: &str, check: &str, summary: &crate::select::summary::Summary) -> u64 {
2854    let ctx = crate::select::Ctx {
2855        session,
2856        check,
2857        channel: "stop",
2858        lessons: std::path::Path::new(""),
2859        summary,
2860        tokens: 0,
2861        had: &[],
2862        alpha: crate::select::policy::ALPHA,
2863    };
2864    crate::select::seed(&ctx) ^ u64::from_be_bytes(*b"tailpool")
2865}
2866
2867/// The `graded` record of one check (outcome.rs `GRADED`, `graded_outcome`), appended beside the store's
2868/// other outcomes (`graded_log`); whether it landed. Nothing with learning off.
2869fn record_graded(
2870    session: &str,
2871    input: &Value,
2872    w: &Work,
2873    applied: &[(&conscience::Challenge, u8)],
2874    decided: Option<(&crate::select::Routed, &Basis)>,
2875    call: &Call,
2876) -> bool {
2877    if !learning() {
2878        return false;
2879    }
2880    let repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
2881    let mut o = graded_outcome(session, &repo, w.check, applied, decided, w.summary, call);
2882    // A detached check's decision is the live channel's (`Work::channel`).
2883    o.channel = w.channel().into();
2884    if w.detached.is_some() {
2885        o.run = crate::check_async::MODE.into();
2886    }
2887    outcome::append(&graded_log(), &o).is_ok()
2888}
2889
2890/// The `graded` record of one check: every check that called the model, one with no applies and a
2891/// failed one (`decided` none; its fit is that of `read`, the summary the check read at its start)
2892/// included, so each decision and each pool can be replayed. Per apply its grade, rank, fill position
2893/// (`Call::order`; a tail lesson's past the budget's, `Call::tail`), propensities to be asked (`p`) and
2894/// handed over free (`pf`); the routes (`chosen`, `free`, `placebo`, and ψ, each apply's chance of the
2895/// placebo draw among those routed nowhere, select.rs `Log::psi`); the applies that could not go free
2896/// because the session had them (`nofree`); the question-count test's k and s on a check it considered
2897/// (`xk`, `xs`, select.rs `Log::xk`); the tail sample (`tail`, `pt`, and after the applies' fill
2898/// positions each tail lesson's, outcome.rs `fill`); the policy, its fit, the summary the decision read
2899/// (`Basis`) and why it fell back to the fixed rule (`reason`); and the call.
2900#[allow(clippy::too_many_arguments)] // reason: one record of a check, from the parts the check holds apart
2901fn graded_outcome(
2902    session: &str,
2903    repo: &str,
2904    check: &str,
2905    applied: &[(&conscience::Challenge, u8)],
2906    decided: Option<(&crate::select::Routed, &Basis)>,
2907    read: &crate::select::summary::Summary,
2908    call: &Call,
2909) -> outcome::Outcome {
2910    let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
2911    let rounded = |p: &[f64]| p.iter().map(|x| outcome::round_p(*x)).collect::<Vec<_>>();
2912    let (routed, basis) = (decided.map(|d| d.0), decided.map(|d| d.1));
2913    let shown: u32 = call.order.len().try_into().unwrap_or(u32::MAX);
2914    let place =
2915        |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));
2916    outcome::Outcome {
2917        kind: outcome::GRADED.into(),
2918        session: session.into(),
2919        lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
2920        ms: learn::now_ms(),
2921        repo: repo.into(),
2922        channel: "stop".into(),
2923        grades: applied.iter().map(|(_, g)| *g).collect(),
2924        ranks: (0..applied.len()).map(|r| u16::try_from(r).unwrap_or(u16::MAX)).collect(),
2925        applied: u16::try_from(applied.len()).unwrap_or(u16::MAX),
2926        chosen: routed.map(|r| ids(&r.ask)).unwrap_or_default(),
2927        explore: routed.is_some_and(|r| r.log.source == "explore"),
2928        p: routed.map(|r| rounded(&r.p)).unwrap_or_default(),
2929        check: check.into(),
2930        policy: routed.map_or_else(|| crate::select::policy().name(), |r| r.log.policy).into(),
2931        fit: routed.map_or_else(|| read.fit.key.clone(), |r| r.log.fit.clone()),
2932        // The summary the decision's `Ctx` read (`decide`), not the log at this append.
2933        seen: basis.map(|b| b.summary.key()).unwrap_or_default(),
2934        source: routed.map(|r| r.log.source).unwrap_or_default().into(),
2935        free: routed.map(|r| ids(&r.free)).unwrap_or_default(),
2936        pf: routed.map(|r| rounded(&r.pf)).unwrap_or_default(),
2937        placebo: routed.map(|r| ids(&r.placebo)).unwrap_or_default(),
2938        psi: routed.map_or(0.0, |r| r.log.psi),
2939        nofree: basis.map(|b| crate::select::nofree(applied, b.had)).unwrap_or_default(),
2940        // The question-count test's k and s (select.rs `Log::xk`, `xs`): None outside it.
2941        xk: routed.and_then(|r| r.log.xk),
2942        xs: routed.and_then(|r| r.log.xs),
2943        // The tolerance it was decided under (select.rs `Log::alpha`): None at the default.
2944        alpha: routed.and_then(|r| r.log.alpha),
2945        tail: call.tail.iter().map(|(_, c)| c.id.clone()).collect(),
2946        pt: call.pt,
2947        reason: basis.map(|b| b.why).unwrap_or_default().into(),
2948        eligible: call.eligible.try_into().unwrap_or(u32::MAX),
2949        shown,
2950        tokens: crate::select::shown_tokens(call.order),
2951        budget: call.budget.try_into().unwrap_or(u32::MAX),
2952        // The applies' places, then each tail lesson's (aligned with `tail`, outcome.rs `fill`).
2953        fill: applied
2954            .iter()
2955            .map(|(c, _)| place(&c.id))
2956            .chain(call.tail.iter().map(|(at, _)| Some(*at)))
2957            .map(|i| i.map_or(u16::MAX, |i| u16::try_from(i).unwrap_or(u16::MAX)))
2958            .collect(),
2959        model: call.model.into(),
2960        prompt_hash: call.prompt_hash.clone(),
2961        harness: std::env::var("BORG_HARNESS").unwrap_or_default(),
2962        version: env!("CARGO_PKG_VERSION").into(),
2963        dur_ms: call.dur_ms,
2964        failed: routed.is_none(),
2965        usd: call.usd,
2966        // What the boundary and the tail showed past the pool's tokens (the latency fit reads both).
2967        xtokens: crate::select::shown_tokens(&call.boundary.iter().chain(call.tail).map(|(_, c)| *c).collect::<Vec<_>>()),
2968        line: call.line.into(),
2969        moves: call.moves.clone(),
2970        whys: call.whys.clone(),
2971        ..Default::default()
2972    }
2973}
2974
2975/// One hook event, canonical already (harness `normalise`), for the drone `h` runs: what borg answers
2976/// (Claude-shaped), or None. One function per event.
2977pub fn dispatch(h: &dyn Harness, event: &str, input: &Value) -> Option<Value> {
2978    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2979    match event {
2980        "prompt" => on_prompt(h, session, input),
2981        "post_tool_failure" => {
2982            capture_test(session, input, true);
2983            if h.caps().deny_capture {
2984                capture_rejection(h, session, input);
2985            }
2986            None
2987        }
2988        "end" => {
2989            on_end(h, session, input);
2990            None
2991        }
2992        "post_tool" => {
2993            capture_test(session, input, false);
2994            on_post_tool(h, session, input)
2995        }
2996        "stop" => on_stop(h, session, input),
2997        _ => None,
2998    }
2999}
3000
3001fn on_prompt(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3002    let prompt = input.get("prompt").and_then(Value::as_str).unwrap_or_default();
3003    append(session, prompt);
3004    if learning() {
3005        // Where the session started, once: its commits later get reasoning notes (notes.rs).
3006        crate::notes::remember_start(&crate::notes::state_dir(), session, input.get("cwd").and_then(Value::as_str).unwrap_or_default());
3007        crate::notes::mark(&crate::notes::state_dir(), session, "prompt");
3008    }
3009    // The engineer's own words, separately from the drone's touches (the queen's criterion 3).
3010    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(prompts_file(session)) {
3011        let _ = writeln!(f, "{prompt}");
3012    }
3013    // "borg no <reason>": the engineer's veto (veto.rs), never the session's task nor a correction.
3014    let vetoed = (learning() && crate::veto::enabled()).then(|| crate::veto::reason_in(prompt)).flatten();
3015    match &vetoed {
3016        Some(reason) => veto(h, session, input, reason),
3017        None => capture_prompt(h, session, input, prompt),
3018    }
3019    note_where(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()));
3020    let _ = std::fs::write(state_file(session).with_extension("lastprompt"), prompt);
3021    // A newer borg (update.rs): the daily release check, started detached when `update.json` is stale
3022    // (one read of it when fresh), and what the drone hears of it below.
3023    let checked = crate::update::maybe_check(learn::now_ms());
3024    // An adapter that will not wait for the answer (`"context": false`), or a harness whose model never
3025    // sees a prompt's context, gets none: what is handed over here is marked delivered and would be lost.
3026    let out = (h.caps().prompt_context && input.get("context") != Some(&Value::Bool(false))).then(|| {
3027        let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
3028        let ask = queen_ask(h, session, cwd);
3029        // Once per session per state (`<session>.update`): a newer release, a member nothing syncs with,
3030        // a member on a newer borg; the routed store's mesh/ is read only when it exists.
3031        let update = crate::update::notice(checked.as_ref(), &learn::home(), &state_file(session).with_extension("update"));
3032        let extra: Vec<String> = [ask, vetoed.as_deref().map(crate::veto::render), update].into_iter().flatten().collect();
3033        free_context_with(h, "UserPromptSubmit", session, input, prompt, (!extra.is_empty()).then(|| extra.join("\n\n")))
3034    });
3035    // A new turn's work starts, after a free delivery here took the last turn's as its evidence.
3036    if harness::transcript(h.caps(), input).is_none() {
3037        let _ = std::fs::write(turn_file(session), "");
3038    }
3039    out.flatten()
3040}
3041
3042/// A shell command that ran tests leaves `fail <ms>` or `pass <ms>` in `<state>/<session>.tests`: the
3043/// regress `tests` signal (`notes/regress_signals.rs`) and `borg learn`'s `tested` credit, whether or not
3044/// the harness reports denials. A call that did not fail and shows no output (an adapter that sends
3045/// none) leaves nothing: its result is unknown, not a pass.
3046fn capture_test(session: &str, input: &Value, failed: bool) {
3047    if !learning() || input.get("tool_name").and_then(Value::as_str) != Some("Bash") {
3048        return;
3049    }
3050    let command = input.pointer("/tool_input/command").and_then(Value::as_str).unwrap_or_default();
3051    if !crate::notes::regress_signals::runs_tests(command) {
3052        return;
3053    }
3054    let output = crate::notes::regress_signals::tool_output(input);
3055    if !failed && output.trim().is_empty() {
3056        return;
3057    }
3058    if let Some(what) = crate::notes::regress_signals::test_outcome(command, &output, failed)
3059        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session).with_extension("tests"))
3060    {
3061        let _ = writeln!(f, "{what} {}", learn::now_ms());
3062    }
3063}
3064
3065fn on_end(h: &dyn Harness, session: &str, input: &Value) {
3066    let _ = std::fs::remove_file(denied_file(session));
3067    if !learning() {
3068        return;
3069    }
3070    // The session's landed commits get a redacted reasoning note, in the background.
3071    crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
3072    crate::notes::mark(&crate::notes::state_dir(), session, "stop");
3073    // What still waits in the queue was never delivered (a resumed session's delivery supersedes it);
3074    // then the end itself, where the selection summary counts the session's deliveries (outcome.rs
3075    // `ENDED`), whatever it logged before.
3076    note_never_delivered(session, input);
3077    let end = outcome::Outcome {
3078        kind: outcome::ENDED.into(),
3079        session: session.into(),
3080        ms: learn::now_ms(),
3081        repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
3082        ..Default::default()
3083    };
3084    let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &end);
3085    // Generalise this session's corrections in the background; never delay the drone. The notes job
3086    // starts it once its links are recorded, so learning sees their regret episodes.
3087    if !crate::notes::on_end(session, input)
3088        && let Ok(exe) = std::env::current_exe()
3089    {
3090        let _ = std::process::Command::new(exe)
3091            .args(["learn", "--session", session])
3092            .stdin(std::process::Stdio::null())
3093            .stdout(std::process::Stdio::null())
3094            .stderr(std::process::Stdio::null())
3095            .spawn();
3096    }
3097}
3098
3099fn on_post_tool(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3100    // The drone moved on from a denial: the next prompt is no longer the engineer's reason.
3101    let _ = std::fs::remove_file(denied_file(session));
3102    let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or_default();
3103    let tool_input = input.get("tool_input").unwrap_or(&Value::Null);
3104    let touch = touched(tool_input, tool);
3105    append(session, &touch);
3106    // A path the drone touched says where it works; a command's free text does not.
3107    note_where(
3108        session,
3109        &if tool == "Bash" { bash_read_paths(tool_input.get("command").and_then(Value::as_str).unwrap_or_default()).join(" ") } else { touch.clone() },
3110    );
3111    // BORG_STOP_GATE=edits: this turn changed something, so its Stop asks the model.
3112    if prefilter::edits_gate() && prefilter::changes(tool, tool_input) {
3113        let _ = std::fs::OpenOptions::new().create(true).append(true).open(changes_file(session)).and_then(|mut f| writeln!(f, "{tool}"));
3114    }
3115    let edit = edit_text(tool, tool_input);
3116    if let Some(edit) = &edit
3117        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
3118    {
3119        let _ = writeln!(f, "{edit}");
3120    }
3121    // No transcript (omp, opencode): the turn's tool calls are the work so far a free delivery reads.
3122    if harness::transcript(h.caps(), input).is_none()
3123        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(turn_file(session))
3124    {
3125        let _ = writeln!(f, "[{tool}] {touch}");
3126        if let Some(edit) = &edit {
3127            let _ = writeln!(f, "{edit}");
3128        }
3129    }
3130    // Context only where the harness's model reads it after a tool call; otherwise it waits for the next
3131    // prompt or the stop. Never at a subagent's tool call: its context goes to the subagent, so what the
3132    // queue hands over there would be recorded delivered to a drone that never saw it.
3133    (h.caps().tool_context && !subagent(input)).then(|| free_context(h, "PostToolUse", session, input, &touch)).flatten()
3134}
3135
3136/// Whether the event is a subagent's (`agent_id`, as msg.rs `deliver` reads it): the session's id, but
3137/// Claude gives the hook's context to the subagent, not to the session's drone.
3138fn subagent(input: &Value) -> bool {
3139    input.get("agent_id").and_then(Value::as_str).is_some_and(|a| !a.is_empty())
3140}
3141
3142/// The stop holds the session lock (live.rs) for its whole check, so the session-end judge of a session
3143/// resumed meanwhile never judges the same deliveries beside it (`judge_session`). It never waits for
3144/// it: when the session-end judge holds the lock, the stop still checks the work, but leaves the
3145/// pending lessons to that judge (`Work::judge`) and asks none of them (`open_in`).
3146struct StopLock {
3147    budget: Option<std::time::Duration>,
3148    guard: Option<live::Guard>,
3149}
3150
3151impl StopLock {
3152    fn take(h: &dyn Harness, session: &str) -> StopLock {
3153        StopLock { budget: crate::expert_route::stop_budget(h), guard: live::acquire(&state_file(session), learn::now_ms()) }
3154    }
3155}
3156
3157fn on_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3158    let started = std::time::Instant::now();
3159    let started_ms = learn::now_ms();
3160    if learning() {
3161        // The drone's latest proposal, for the reasoning note of what it commits (notes.rs).
3162        crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
3163        crate::notes::mark(&crate::notes::state_dir(), session, "stop");
3164        // What this turn added to the harness's own transcript: its corrections and answers (sweep.rs).
3165        crate::sweep::turn_end(h, session, input);
3166    }
3167    let second_round = input.get("stop_hook_active").and_then(Value::as_bool) == Some(true);
3168    if std::env::var("BORG_CRITIC").as_deref() == Ok("generic") {
3169        return (!second_round).then(|| json!({"decision": "block", "reason": conscience::GENERIC}));
3170    }
3171    let (all, withheld) = loaded(session);
3172    let judged = judged(&all, withheld);
3173    // R1: the drone's answer to the last block's questions, at the first turn end after it: evidence
3174    // for the judge at the next model call (`pending`), not a label.
3175    note_answer(h, session, input);
3176    // BORG_STOP_GATE=edits: whether this turn changed anything; every Stop consumes the marker, so
3177    // the rework after a block does not count for the next turn.
3178    let changed = std::fs::remove_file(changes_file(session)).is_ok();
3179    // One round of self-questioning per turn (the harness's stop_hook_active); never loop.
3180    if second_round {
3181        return None;
3182    }
3183    stat(session, input, crate::stats::TURN_END, |_| {});
3184    if detached() {
3185        return stop_detached(h, session, input, &all, &judged, changed, started_ms);
3186    }
3187    let lock = StopLock::take(h, session);
3188    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3189    let proposal = full_proposal(h, input);
3190    let (done, mut challenges) = askable(session, &all, &judged, &state, &proposal, lock.guard.is_some(), false, None);
3191    // The pool's lines show each lesson's correction (whole.rs `line`, `A2`).
3192    conscience::with_corrections(&lessons_path(), &mut challenges);
3193    let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
3194    let context = format!("{state}{message}");
3195    // Scopes at Stop: where the session works plus this turn's own message, not the whole history.
3196    let scope_text = format!("{}\n{message}", where_now(session));
3197    let check_id = crate::select::check_id(session, "stop", started_ms, std::process::id());
3198    // Folds the log's new tail and saves it, so the next check reads only what came after.
3199    let summary = crate::select::summary::refresh(&lessons_path());
3200    let work = Work {
3201        all: &all,
3202        judged: &judged,
3203        challenges: &challenges,
3204        done: &done,
3205        state: &state,
3206        context: &context,
3207        scope_text: &scope_text,
3208        message: &proposal,
3209        changed,
3210        judge: lock.guard.is_some(),
3211        budget: lock.budget,
3212        started,
3213        detached: None,
3214        edits: None,
3215        check: &check_id,
3216        summary: &summary,
3217    };
3218    let out = match stop_select(h, session, input, &work) {
3219        Ok(selected) => stop_ask(h, session, input, selected, &check_id),
3220        Err(out) => out,
3221    };
3222    drop(lock);
3223    out
3224}
3225
3226/// The lessons a stop's check may ask (`Work::challenges`) of `all`, and those done in the session
3227/// (`Work::done`), from its state file `state` and the drone's message `proposal`. `judge`: whether the
3228/// check judges the pending lessons (it holds the session lock, or no one does); without, it leaves the
3229/// open deliveries to the holder. `regraded`: a detached check's, which grades waiting asks again
3230/// (`waiting_in`).
3231#[allow(clippy::too_many_arguments)] // reason: the stop's view of the session, as each caller holds it
3232fn askable<'s>(
3233    session: &str,
3234    all: &[conscience::Challenge],
3235    judged: &[conscience::Challenge],
3236    state: &'s str,
3237    proposal: &str,
3238    judge: bool,
3239    regraded: bool,
3240    edits: Option<usize>,
3241) -> (std::collections::HashSet<&'s str>, Vec<conscience::Challenge>) {
3242    // A lesson is asked once per session: the drone answered it at that turn end, and asking it again
3243    // at every later one is noise (and a model call spent on it). R3b: a lesson delivered early or
3244    // handed over free (`#borg-early`, `#borg-queued`) is done once the drone acted on it; one it did
3245    // not act on may still be asked (`done_in`).
3246    let acted = early_acted(session, judged, proposal, edits);
3247    let only = std::env::var_os("BORG_ONLY").is_some();
3248    let done = if only { std::collections::HashSet::new() } else { done_in(state, &acted) };
3249    // What still waits is decided already: the free lessons of a check whose line no delivery took yet
3250    // (an earlier stop's at a harness without prompt context; `queued` hands them over). Out of this
3251    // stop's pool (`waiting_in`), so one check decides each.
3252    let queue = if only { String::new() } else { std::fs::read_to_string(queued_file(session)).unwrap_or_default() };
3253    // An ask a wake reserved (`reserve_asks`) is on its way: never graded again.
3254    let reserved = if only { String::new() } else { std::fs::read_to_string(reserved_file(session)).unwrap_or_default() };
3255    let waiting: std::collections::HashSet<&str> =
3256        if only { Default::default() } else { waiting_in(&queue, regraded).chain(waiting_in(&reserved, false)).collect() };
3257    // Without the lock, the session-end judge of a resumed session holds it and judges every open
3258    // delivery: a lesson it may be judging is not delivered again here, or its verdict on the earlier
3259    // delivery (`#borg-judged`, `END_JUDGED`) would land on this stop's question and settle it unjudged.
3260    let ending: std::collections::HashSet<&str> = if judge { Default::default() } else { open_in(state) };
3261    // Lessons of other systems (systems.rs) are neither shortlisted nor asked.
3262    let challenges = all
3263        .iter()
3264        .filter(|c| !done.contains(c.id.as_str()) && !waiting.contains(c.id.as_str()) && !ending.contains(c.id.as_str()) && !c.off_system)
3265        .cloned()
3266        .collect();
3267    (done, challenges)
3268}
3269
3270/// Whether this stop checks detached (check_async.rs): `BORG_ASYNC` on, and the stop's check is the
3271/// consequence model's graded call (`stop_select`, `whole::graded`). The evaluation modes (BORG_ONLY,
3272/// BORG_MATCH=bm25, BORG_STOP_GRADED=off) keep the stop's own check.
3273fn detached() -> bool {
3274    crate::check_async::on() && std::env::var_os("BORG_ONLY").is_none() && std::env::var("BORG_MATCH").as_deref() != Ok("bm25") && crate::whole::graded()
3275}
3276
3277/// Protocol: the state line a detached check's stop writes, `#borg-checking @<check>`: the turn ends
3278/// at the stop (prefilter.rs `MARKERS`), never at the check's later lines.
3279const CHECKING: &str = "#borg-checking";
3280
3281/// The stop of a detached check (check_async.rs): no session lock, no model call, no wait. The free gate
3282/// runs here (`gate`; a skip as at any stop, `skipped`); past it, the turn goes to a job file
3283/// (check_async.rs `Job`: the state file's length, the drone's message, where it works), the turn ends
3284/// (`CHECKING`) and `borg check --job` starts detached. Its asks reach the drone with the next prompt
3285/// or tool call (`queued`). The stop answers as a skip does: no block, the queen may ask.
3286fn stop_detached(
3287    h: &dyn Harness,
3288    session: &str,
3289    input: &Value,
3290    all: &[conscience::Challenge],
3291    judged: &[conscience::Challenge],
3292    changed: bool,
3293    started_ms: i64,
3294) -> Option<Value> {
3295    crate::check_async::sweep(session);
3296    reclaim_reserved(session);
3297    let ctx = state_file(session);
3298    let state = std::fs::read_to_string(&ctx).unwrap_or_default();
3299    // The .edits file's length with it: the turn the check judges ends here in both.
3300    let edits = std::fs::metadata(edits_file(session)).ok().and_then(|m| usize::try_from(m.len()).ok());
3301    let proposal = full_proposal(h, input);
3302    // The session-end judge holds the lock: the check leaves the open deliveries to it (`askable`).
3303    let judge = live::lock_state(&ctx, learn::now_ms(), live::stale_ms(), live::alive) != live::Lock::Held;
3304    let (_, challenges) = askable(session, all, judged, &state, &proposal, judge, true, None);
3305    let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
3306    if let Some(why) = gate(&challenges, &format!("{state}{message}"), changed) {
3307        return skipped(h, session, input, why);
3308    }
3309    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
3310    let job = crate::check_async::Job {
3311        v: crate::check_async::JOB_VERSION,
3312        session: session.into(),
3313        check: crate::select::check_id(session, "stop", started_ms, std::process::id()),
3314        stop_ms: started_ms,
3315        harness: h.name().into(),
3316        cwd: cwd.into(),
3317        offset: state.len(),
3318        message: message.into(),
3319        proposal,
3320        changed,
3321        repo: repo_of(cwd),
3322        scope: format!("{}\n{message}", where_now(session)),
3323        edits,
3324    };
3325    let written = crate::check_async::write_job(&job);
3326    append(session, &format!("{CHECKING}{}", tag(&job.check)));
3327    // A job that cannot be written or started is counted (`borg status`), never lost unseen.
3328    if !written.is_ok_and(|path| crate::check_async::spawn(&path)) {
3329        crate::check_async::not_run(&job, crate::check_async::WHY_UNSTARTED);
3330    }
3331    queen_at_stop(h, session, input)
3332}
3333
3334/// A stop that asks nothing: the queen's question (queen.rs `stop`), when it has one.
3335fn queen_at_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
3336    let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
3337    crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &[])
3338}
3339
3340/// A stop the free gate (or an empty pool) skipped: `#borg-skip <why>`, its stat, the queen's question.
3341fn skipped(h: &dyn Harness, session: &str, input: &Value, why: String) -> Option<Value> {
3342    append(session, &format!("#borg-skip {why}"));
3343    stat(session, input, crate::stats::GATE_SKIP, |e| e.why.clone_from(&why));
3344    queen_at_stop(h, session, input)
3345}
3346
3347/// Whether `session` ended since a detached check's stop: its presence reads ended (presence.rs
3348/// `state`: the end hook ran, or the harness process is gone), or the session-end judge ran past `offset`
3349/// in its state file.
3350pub(crate) fn gone(session: &str, offset: usize) -> bool {
3351    let ended = crate::presence::read(&learn::home(), session)
3352        .is_some_and(|p| crate::presence::state(&p, learn::now_ms(), crate::presence::alive) == crate::presence::State::Ended);
3353    ended || {
3354        let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3355        state.get(offset..).unwrap_or_default().lines().any(|l| l.starts_with(END_JUDGED))
3356    }
3357}
3358
3359/// How often a detached check tries to take the session lock for its write (`run_job`), each after the
3360/// lock read free or stale. Guard: a lock file that cannot be written would otherwise be tried forever.
3361const LOCK_TRIES: usize = 3;
3362
3363/// A detached check's job (check_async.rs), in the process it started:the stop's check as the stop
3364/// would have run it, on the turn the job holds (the state file up to its `offset`, the drone's message,
3365/// where it worked), decided on channel live within its own limit (check_async.rs `call_limit`), the
3366/// model called without any lock. Its answer is written under the session lock (live.rs), taken only
3367/// then (`settle`, `Late`): the pending lessons it judges were open at the stop, and the session-end
3368/// judge (holding the lock at its start) keeps them. Nothing when the session ended before the call;
3369/// what it did otherwise (`Checked`, written under the lock, with the session's check count).
3370pub(crate) fn run_job(job: &crate::check_async::Job) -> Option<crate::check_async::Checked> {
3371    let session = job.session.as_str();
3372    let input = json!({"session_id": session, "cwd": job.cwd});
3373    if gone(session, job.offset) {
3374        crate::check_async::not_run(job, crate::check_async::WHY_GONE);
3375        return None;
3376    }
3377    let ctx = state_file(session);
3378    let now = std::fs::read_to_string(&ctx).unwrap_or_default();
3379    let state = now.get(..job.offset).unwrap_or(&now);
3380    let (all, withheld) = loaded(session);
3381    let judged = judged(&all, withheld);
3382    let judge = live::lock_state(&ctx, learn::now_ms(), live::stale_ms(), live::alive) != live::Lock::Held;
3383    let (done, mut challenges) = askable(session, &all, &judged, state, &job.proposal, judge, true, job.edits);
3384    conscience::with_corrections(&lessons_path(), &mut challenges);
3385    let context = format!("{state}{}", job.message);
3386    let summary = crate::select::summary::refresh(&lessons_path());
3387    let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
3388    let work = Work {
3389        all: &all,
3390        judged: &judged,
3391        challenges: &challenges,
3392        done: &done,
3393        state,
3394        context: &context,
3395        scope_text: &job.scope,
3396        message: &job.proposal,
3397        changed: job.changed,
3398        judge,
3399        budget: None,
3400        started: std::time::Instant::now(),
3401        detached: Some(crate::check_async::call_limit(&model)),
3402        edits: job.edits,
3403        check: &job.check,
3404        summary: &summary,
3405    };
3406    let text = conscience::work_text(&context);
3407    let pool = match pool(session, &work, &text) {
3408        Ok(pool) => pool,
3409        Err(_) => {
3410            // Nothing to ask, none to judge: no call; the turn ended at the stop already.
3411            stat(session, &input, crate::stats::GATE_SKIP, |e| {
3412                e.why = "empty".into();
3413                e.mode = crate::check_async::MODE.into();
3414            });
3415            let mut out = crate::check_async::Checked::of(job);
3416            crate::check_async::finish(session, &mut out, false);
3417            return Some(out);
3418        }
3419    };
3420    let called = call(session, &input, &work, &pool, &text);
3421    let dur_ms = called.dur_ms;
3422    let (shown, cost) = (pool.shortlist.len(), called.pred.as_ref().map(|p| p.cost));
3423    // The only lock this check holds, for its write alone; a holder that renews it (the session-end
3424    // judge's batches) is waited for, one that stopped goes stale. A lock that cannot be written at all
3425    // is no reason to drop the answer: written without it, as `live::locked` appends.
3426    let wait = u64::try_from(live::stale_ms()).unwrap_or(u64::MAX);
3427    let guard = (0..LOCK_TRIES).find_map(|_| {
3428        while live::wait_free(&ctx, wait) == live::Lock::Held {}
3429        live::acquire(&ctx, learn::now_ms())
3430    });
3431    let late = Late { offset: job.offset, gone: gone(session, job.offset), proposal: &job.proposal, stop_ms: job.stop_ms };
3432    let s = settle(session, &input, &work, pool, called, Some(&late));
3433    let mut out = crate::check_async::Checked::of(job);
3434    out.dur_ms = dur_ms;
3435    out.superseded = s.superseded;
3436    match s.verdict {
3437        Verdict::Failed => out.failed = true,
3438        Verdict::Ask(asks) => {
3439            out.asks = asks;
3440            out.free = s.free;
3441        }
3442        Verdict::Skip(_) => {}
3443    }
3444    crate::check_async::finish(session, &mut out, true);
3445    // The pool log's scores (pools.rs) read the store and may load the embedder: after the lock.
3446    drop(guard);
3447    if let Some(p) = &s.pool {
3448        log_pool(session, &work, p);
3449    }
3450    note_check(session, &input, shown, dur_ms, cost, work.detached);
3451    Some(out)
3452}
3453
3454/// The lessons this stop asks; `Err` with the hook's answer when it asks none (a gate skip still lets
3455/// the queen ask).
3456fn stop_select<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
3457    match std::env::var("BORG_ONLY") {
3458        // BORG_ONLY: eval oracle, perfect retrieval = exactly these challenge ids.
3459        Ok(ids) => Ok(w.challenges.iter().filter(|c| ids.split(',').any(|i| i == c.id)).collect()),
3460        // Word matching alone always finds *some* lesson, so it asked at every turn end; the
3461        // consequence model may abstain, and it is the mechanism the sealed eval measured.
3462        Err(_) if std::env::var("BORG_MATCH").as_deref() != Ok("bm25") => stop_check(h, session, input, w),
3463        // R3b (conscience.rs `budget`): block only for a lesson near the best match not yet done;
3464        // then ask everything over the low bar, in-scope first.
3465        Err(_) => {
3466            let pool = conscience::budget_pool(&conscience::rank_scoped(w.all, &conscience::work_text(w.context), w.scope_text), w.done);
3467            let asked: Vec<&conscience::Challenge> = conscience::in_scope_first(conscience::explored(pool.iter().copied()))
3468                .into_iter()
3469                .filter_map(|c| w.challenges.iter().find(|x| x.id == c.id))
3470                .collect();
3471            append_p_of(session, &pool, &asked, w.check);
3472            Ok(asked)
3473        }
3474    }
3475}
3476
3477/// The consequence model's verdict at the stop.
3478fn stop_check<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
3479    match check(session, input, w) {
3480        Verdict::Skip(why) => Err(skipped(h, session, input, why)),
3481        Verdict::Failed => Err(None),
3482        Verdict::Ask(ids) => Ok(ids.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).collect()),
3483    }
3484}
3485
3486/// `#borg-graded n=<applied> <id>:<grade>:<rank>,…`: a graded check's applies in the model's order, so
3487/// the `asked` outcome can carry each asked lesson's grade and rank (`graded_of`).
3488fn graded_line(applied: &[(&conscience::Challenge, u8)]) -> String {
3489    let items: Vec<String> = applied.iter().enumerate().map(|(r, (c, g))| format!("{}:{g}:{r}", c.id)).collect();
3490    format!("#borg-graded n={} {}", applied.len(), items.join(","))
3491}
3492
3493/// `#borg-p <id>=<p>,… @<check>`: the propensity (outcome.rs `p`) of each lesson a check chose to ask,
3494/// so every entry is above 0 and the latest entry per id is the check that asked it; the `asked`
3495/// outcome carries them (`tagged_p`, `propensity_in`). A graded check's `#borg-pf` line is the same for
3496/// what it hands over free. `check` is the check's id (select.rs `check_id`); none on an older
3497/// binary's line. Nothing when no lesson was chosen.
3498fn p_line<'a>(prefix: &str, chosen: impl IntoIterator<Item = (&'a str, f64)>, check: Option<&str>) -> Option<String> {
3499    let items: Vec<String> = chosen.into_iter().map(|(id, p)| format!("{id}={}", outcome::round_p(p))).collect();
3500    (!items.is_empty()).then(|| format!("{prefix} {}{}", items.join(","), tag(check.unwrap_or_default())))
3501}
3502
3503/// `p_line` for lessons `budget`/`widen` asked from `pool`: their chance of a slot under the
3504/// session's posterior (conscience.rs `propensity`). Then the pool itself, `#borg-pool
3505/// <id>=<base>[:s],… @<check>` in ranking order (`:s` a scoped lesson, base conscience.rs `pool_base`,
3506/// unrounded), so the `asked` record can carry it (`pool_in`) and `borg debug ope` can replay the draw. Both
3507/// lines carry the check that drew (its id, one draw per check), which ties the p of an asked lesson
3508/// to the pool its draw was over.
3509fn append_p_of(session: &str, pool: &[(f64, &conscience::Challenge)], asked: &[&conscience::Challenge], check: &str) {
3510    if asked.is_empty() {
3511        return;
3512    }
3513    let p = conscience::propensity(pool, session);
3514    let chosen: Vec<(&str, f64)> = asked.iter().filter_map(|c| p.get(&c.id).map(|p| (c.id.as_str(), *p))).collect();
3515    let items: Vec<String> =
3516        pool.iter().map(|(s, c)| format!("{}={}{}", c.id, conscience::pool_base(*s, c), if c.scope.is_empty() { "" } else { ":s" })).collect();
3517    let pool = format!("#borg-pool {}{}", items.join(","), tag(check));
3518    append(session, &p_line("#borg-p", chosen, Some(check)).map_or(pool.clone(), |p| format!("{p}\n{pool}")));
3519}
3520
3521/// Splits `line` into its items and its ` @<check>` tag (None without one).
3522fn tagged(line: &str) -> (&str, Option<&str>) {
3523    match line.rsplit_once(" @") {
3524        Some((items, check)) if !check.is_empty() && check.bytes().all(|b| b.is_ascii_hexdigit()) => (items, Some(check)),
3525        _ => (line, None),
3526    }
3527}
3528
3529/// The pool behind `ids` (`append_p_of`): the `#borg-pool` line of the check that wrote the latest
3530/// `#borg-p` entry of every one of them, as (ids, bases, scoped); empty when their latest entries come
3531/// from different checks, from a check without a pool, or from a line before checks were tagged (never
3532/// a guess: the p of one draw is never paired with another draw's pool).
3533fn pool_in(state: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
3534    let parse = |line: &str| -> Option<(Vec<String>, Vec<f64>, Vec<bool>)> {
3535        let mut out = (Vec::new(), Vec::new(), Vec::new());
3536        for item in line.split(',') {
3537            let (id, rest) = item.rsplit_once('=')?;
3538            let (base, scoped) = match rest.strip_suffix(":s") {
3539                Some(b) => (b, true),
3540                None => (rest, false),
3541            };
3542            out.0.push(id.trim().to_string());
3543            out.1.push(base.trim().parse::<f64>().ok()?);
3544            out.2.push(scoped);
3545        }
3546        Some(out)
3547    };
3548    // The check of each id's latest p entry.
3549    let mut check: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
3550    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
3551        let (items, tag) = tagged(line);
3552        for (id, _) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
3553            check.insert(id.trim(), tag);
3554        }
3555    }
3556    let Some(Some(tag)) = ids.first().and_then(|i| check.get(i.as_str())).copied() else { return Default::default() };
3557    if !ids.iter().all(|i| check.get(i.as_str()).copied().flatten() == Some(tag)) {
3558        return Default::default();
3559    }
3560    state
3561        .lines()
3562        .rev()
3563        .filter_map(|l| l.strip_prefix("#borg-pool "))
3564        .map(tagged)
3565        .filter(|(_, t)| *t == Some(tag))
3566        .find_map(|(items, _)| parse(items).filter(|(pool, _, _)| ids.iter().all(|i| pool.contains(i))))
3567        .unwrap_or_default()
3568}
3569
3570/// For each of `ids`, its propensity in the state file's latest `#borg-p` entry for it, whatever check
3571/// wrote it (a line of an older binary has no tag); empty when any of them has none (unknown, never a
3572/// guess).
3573fn propensity_in(state: &str, ids: &[String]) -> Vec<f64> {
3574    let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
3575    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
3576        for (id, p) in tagged(line).0.split(',').filter_map(|it| it.rsplit_once('=')) {
3577            if let Ok(p) = p.trim().parse::<f64>() {
3578                seen.insert(id.trim(), p);
3579            }
3580        }
3581    }
3582    ids.iter().map(|i| seen.get(i.as_str()).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
3583}
3584
3585/// For each of `ids`: its grade and rank in the session's latest graded check that graded it, and that
3586/// check's number of applies (the largest when the ids come from several checks); (0, 0) and 0 when none.
3587fn graded_of(session: &str, ids: &[String]) -> (Vec<u8>, Vec<u16>, u16) {
3588    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3589    let mut seen: std::collections::HashMap<String, (u8, u16, u16)> = std::collections::HashMap::new();
3590    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")) {
3591        let Some((n, items)) = line.split_once(' ') else { continue };
3592        let n: u16 = n.parse().unwrap_or(0);
3593        for it in items.split(',') {
3594            let mut p = it.rsplitn(3, ':');
3595            let (Some(r), Some(g), Some(id)) = (p.next(), p.next(), p.next()) else { continue };
3596            seen.insert(id.to_string(), (g.parse().unwrap_or(0), r.parse().unwrap_or(0), n));
3597        }
3598    }
3599    let got: Vec<(u8, u16, u16)> = ids.iter().map(|i| seen.get(i).copied().unwrap_or((0, 0, 0))).collect();
3600    (got.iter().map(|g| g.0).collect(), got.iter().map(|g| g.1).collect(), got.iter().map(|g| g.2).max().unwrap_or(0))
3601}
3602
3603/// Outcome tracking (outcome.rs) of `lessons` asked on `channel` (stop; live, a check's ask from before
3604/// 0.7.6), routed by `check`: `borg learn` later sees whether a correction followed. The `asked` record
3605/// names the check and carries its p and pool, so a verdict and the settle land on the check that decided
3606/// the lesson; then its `delivery` record. Nothing with learning off or without lessons.
3607fn record_asked(session: &str, input: &Value, lessons: &[&conscience::Challenge], channel: &str, check: &str) {
3608    if !learning() || lessons.is_empty() {
3609        return;
3610    }
3611    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
3612    let ids: Vec<String> = lessons.iter().map(|c| c.id.clone()).collect();
3613    let members: Vec<&str> = ids.iter().map(String::as_str).collect();
3614    let (grades, ranks, applied) = graded_of(session, &ids);
3615    let p = if check.is_empty() { propensity_in(&state, &ids) } else { tagged_p(&state, "#borg-p ", check, &members) };
3616    let (pool, base, scoped) = pool_in(&state, &ids);
3617    let asked = outcome::Outcome {
3618        kind: outcome::ASKED.into(),
3619        session: session.into(),
3620        lessons: ids.clone(),
3621        ms: learn::now_ms(),
3622        repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
3623        // The asked lessons' disagreements (outcome.rs `contradicted` reads them across the session's records).
3624        groups: conscience::disagreement_ids(lessons),
3625        channel: channel.into(),
3626        grades,
3627        ranks,
3628        applied,
3629        p,
3630        pool,
3631        base,
3632        scoped,
3633        check: check.into(),
3634        ..Default::default()
3635    };
3636    let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
3637    record_route(session, input, check, ids, channel, outcome::ROUTE_ASKED, "");
3638}
3639
3640/// The stop asks `selected`, routed by its `check`: one block (the queen's, when it asks too),
3641/// `#borg-asked <ids> @<check>`, its `asked` record and its `delivery` record.
3642fn stop_ask(h: &dyn Harness, session: &str, input: &Value, mut lessons: Vec<&conscience::Challenge>, check: &str) -> Option<Value> {
3643    // Shown in an order stable within the session but different across sessions: no lesson always
3644    // sits first (position bias; memory.rs `shuffle`).
3645    crate::memory::shuffle(&mut lessons, session);
3646    let log = lessons.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
3647    // An empty `#borg-asked` still ends the turn (prefilter.rs `turn`): it carries no tag.
3648    let marker = if lessons.is_empty() { "#borg-asked ".to_string() } else { format!("#borg-asked {log}{}", tag(check)) };
3649    append(session, &marker);
3650    if !lessons.is_empty()
3651        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
3652    {
3653        // The blocked message is the work before the questions (R1 `pending`).
3654        let _ = writeln!(f, "{}\n{marker}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
3655    }
3656    // L4 (queen.rs): a rare, budgeted question to the engineer when the conscience had nothing
3657    // or its lessons contradict each other (its block then carries the lessons too).
3658    let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
3659    let queen = crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &lessons);
3660    if lessons.is_empty() {
3661        return queen;
3662    }
3663    record_asked(session, input, &lessons, "stop", check);
3664    let out = queen.or_else(|| Some(json!({"decision": "block", "reason": conscience::render(&lessons)})));
3665    let chars = out.as_ref().and_then(|o| o["reason"].as_str()).map_or(0, str::len);
3666    log_cost(session, &format!("block questions={} chars={chars}", lessons.len()));
3667    stat(session, input, crate::stats::BLOCK, |e| {
3668        e.n = lessons.len() as u64;
3669        e.lessons = lessons.iter().map(|c| c.id.clone()).collect();
3670        e.channel = "stop".into();
3671    });
3672    out
3673}
3674
3675/// `borg hook <harness> <event>`, the event on stdin: routes the repo to its collective (an off repo
3676/// leaves no trace), records presence, then answers through steering, the experts and [`dispatch`],
3677/// with the messages from other sessions (msg.rs).
3678/// An unknown harness, like any failure here, lets the drone continue untouched.
3679pub fn main(name: &str, event: &str) {
3680    let Some(h) = harness::by_name(name) else { return };
3681    let mut raw = String::new();
3682    let _ = std::io::stdin().read_to_string(&mut raw);
3683    let mut input = h.normalise(serde_json::from_str(&raw).unwrap_or(Value::Null));
3684    // A pid only from a harness that sends its own: presence reads a gone one as an ended drone.
3685    if !h.caps().sends_pid
3686        && let Some(m) = input.as_object_mut()
3687    {
3688        m.shift_remove("pid");
3689    }
3690    // Which mind is this repo part of? Decided before anything is read or written: an off repo
3691    // (or a broken config) leaves no trace at all.
3692    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
3693    let cfg = collective::load();
3694    let route = match &cfg {
3695        Ok(cfg) => collective::route(cfg.as_ref(), cwd, &learn::home()),
3696        Err(_) => collective::Route::Off,
3697    };
3698    let collective::Route::Collective { home, name, .. } = route else { return };
3699    // SAFETY: single-threaded at this point; everything below (and the background `borg learn` it
3700    // spawns) inherits the collective's own store and scratch state.
3701    unsafe {
3702        std::env::set_var("BORG_HOME", &home);
3703        // borg's own model calls go through the drone's harness (runner.rs); `borg learn` reads it too.
3704        std::env::set_var("BORG_HARNESS", h.name());
3705        if std::env::var_os("BORG_STATE").is_none() {
3706            std::env::set_var("BORG_STATE", home.join("state"));
3707        }
3708    }
3709    // Who is working where (presence.rs, for `borg top`): one small atomic write per hook.
3710    let now = learn::now_ms();
3711    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
3712    // The turn of a session that listens for wakes (listen.rs): one stat of its opt-in for any other, at
3713    // a prompt, a tool call's start or end, a halted turn and the end. With steering's stat below, an idle
3714    // tool call costs two failed stats: the opt-in is per session, the admissions are per store.
3715    crate::listen::touch(&home, event, session, now);
3716    // A session's start (a hook of an older install) and a halted turn read and write nothing more.
3717    if matches!(event, "start" | "halted") {
3718        return;
3719    }
3720    // Not at pre_tool (every tool call): there only steering reads, once someone steered (steer.rs
3721    // `active`, the second stat).
3722    let me = match event {
3723        "pre_tool" => {
3724            if !crate::steer::active(&home) {
3725                return;
3726            }
3727            crate::presence::read(&home, session)
3728        }
3729        _ => crate::presence::touch(&home, &name, h.name(), event, &input, now),
3730    };
3731    // Steering (steer.rs) wraps the answer: a pause refuses tools and lets the stop wait.
3732    // At a stop, the routed collective's experts join the conscience's block (expert_route.rs).
3733    let started = me.map(|p| p.started);
3734    // A listening session's stop holds its wakes off until its answer is out (listen.rs).
3735    let stopping = crate::listen::stopping(&home, event, session, now);
3736    let out = crate::steer::around(&home, event, &input, started, now, || crate::expert_route::checkpoint(h, event, &input, || dispatch(h, event, &input)));
3737    // A listening session's stop starts its waiter, or asks for it (listen.rs).
3738    let out = crate::listen::after_stop(&home, h, stopping.as_ref(), &input, out);
3739    // An adapter that asked learns its wait budgets from the answer (harness.rs `Budgets`).
3740    let out = harness::with_budgets(h, &input, out);
3741    // Messages from other sessions (msg.rs) join a prompt's or tool call's context, each once.
3742    if let Some(out) = crate::msg::deliver(&home, h.caps(), event, &input, started, now, out) {
3743        println!("{}", for_harness(h, out));
3744    }
3745}
3746
3747/// The answer as `h` reads it: `"shown_ids"` (which messages it showed) is for an adapter, which drops a
3748/// held wake (listen.rs) once every message it names was shown; a harness that parses the answer itself
3749/// (Claude Code, Codex) gets only the keys it defines.
3750fn for_harness(h: &dyn Harness, mut out: Value) -> Value {
3751    if matches!(h.wiring(), harness::Wiring::CommandHooks { .. })
3752        && let Some(o) = out.as_object_mut()
3753    {
3754        o.shift_remove("shown_ids");
3755    }
3756    out
3757}
3758
3759#[cfg(test)]
3760mod tests {
3761    use super::*;
3762
3763    #[test]
3764    fn every_file_of_an_edit_survives_the_cut_of_its_text() {
3765        let long = "x".repeat(700);
3766        let input = json!({"file_path": "/w/a.rs", "new_string": long, "edits": [{"file_path": "/w/b.rs", "new_string": "more"}]});
3767        let text = edit_text("Edit", &input).unwrap();
3768        assert!(text.starts_with("/w/a.rs /w/b.rs x"), "{}", &text[..40]);
3769        assert_eq!(text.chars().count(), 600);
3770        let multi = json!({"file_path": "/w/a.rs", "edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "c", "new_string": "d"}]});
3771        assert_eq!(edit_text("MultiEdit", &multi).as_deref(), Some("/w/a.rs b d"), "Claude's MultiEdit reads as before");
3772        assert_eq!(touched(&input, "Edit"), "/w/a.rs /w/b.rs");
3773    }
3774
3775    #[test]
3776    fn a_waiting_ask_is_graded_again_and_a_waiting_hand_over_is_not() {
3777        let c = "0123456789ab";
3778        let queue = format!("A,B @{c}\ninstead-next C @{c}\nlive D @{c}\nlive-next E\nplacebo F @{c}\n");
3779        assert_eq!(waiting_in(&queue, true).collect::<Vec<_>>(), ["A", "B", "C"], "a detached check's");
3780        assert_eq!(waiting_in(&queue, false).collect::<Vec<_>>(), ["A", "B", "C", "D", "E"], "a stop's own check's, as before");
3781    }
3782
3783    #[test]
3784    fn a_detached_checks_questions_name_the_turn_it_judged() {
3785        let edits = "#borg-proposal 0123456789ab first try\nwork\n#borg-proposal 0123456789ab I drop the token.\n#borg-proposal 00000000000f other\n";
3786        assert_eq!(proposal_of(edits, "0123456789ab"), Some("I drop the token."), "the latest line of that check");
3787        assert_eq!(proposal_of(edits, "0123456789"), None, "a whole id only");
3788        assert_eq!(proposal_of(edits, ""), None, "a line of an older binary has no check");
3789        let l = conscience::Challenge { id: "L1".into(), question: "Is the budget drained?".into(), ..Default::default() };
3790        let text = conscience::render_late(&[&l], &one_line("I drop\n the token.", LATE_EXCERPT));
3791        assert!(text.contains("your turn that ended with \"I drop the token.…\"") && text.contains("1. [L1] Is the budget drained?"), "{text}");
3792        assert!(text.contains("`<id>: applies`") && text.contains("already settled"), "{text}");
3793        assert!(conscience::render_late(&[&l], "").contains("After your last turn"));
3794    }
3795
3796    #[test]
3797    fn propensity_in_takes_the_latest_entry_and_nothing_when_one_is_missing() {
3798        let state = "#borg-p A=1,B=0.25\n#borg-asked A,B\n#borg-p B=0.5,C=0.1\n";
3799        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3800        assert_eq!(propensity_in(state, &ids(&["B", "A"])), [0.5, 1.0]);
3801        assert_eq!(propensity_in(state, &ids(&["C"])), [0.1]);
3802        assert!(propensity_in(state, &ids(&["A", "D"])).is_empty(), "D unknown: no guess");
3803        assert!(propensity_in("", &ids(&["A"])).is_empty());
3804    }
3805
3806    #[test]
3807    fn pool_in_takes_the_pool_of_the_check_that_logged_each_p() {
3808        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";
3809        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3810        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");
3811        assert_eq!(pool_in(state, &ids(&["A"])), (ids(&["A", "B", "C"]), vec![1.5, 0.25, 2.0], vec![true, false, false]));
3812        assert_eq!(pool_in(state, &ids(&["A", "B"])), Default::default(), "A's p from check 1, B's from check 2: no pairing");
3813        assert_eq!(pool_in("", &ids(&["A"])), Default::default());
3814        assert_eq!(pool_in(state, &[]), Default::default());
3815        // A graded check's p has no pool: a later pool that happens to hold the lesson is not its.
3816        let graded = "#borg-p A=1\n#borg-p B=0.5 @c3\n#borg-pool A=1,B=1 @c3\n";
3817        assert_eq!(pool_in(graded, &ids(&["A"])), Default::default());
3818        assert_eq!(pool_in(graded, &ids(&["B"])).0, ids(&["A", "B"]));
3819        // A state file from before checks were tagged: no pool.
3820        assert_eq!(pool_in("#borg-p A=1\n#borg-pool A=1,B=2\n", &ids(&["A"])), Default::default());
3821    }
3822
3823    #[test]
3824    fn a_queue_line_names_its_route_its_ids_and_its_check() {
3825        let c = "0123456789ab";
3826        assert_eq!(queue_line(&format!("A,B @{c}")), (Queued::Free, vec!["A", "B"], c));
3827        assert_eq!(queue_line(&format!("placebo X,Y @{c}")), (Queued::Placebo, vec!["X", "Y"], c));
3828        assert_eq!(queue_line(&format!("instead G0,G1 @{c}")), (Queued::Instead, vec!["G0", "G1"], c));
3829        assert_eq!(queue_line(&format!("instead-next G2 @{c}")), (Queued::Instead, vec!["G2"], c));
3830        // An older binary's lines: no check.
3831        assert_eq!(queue_line("A,B"), (Queued::Free, vec!["A", "B"], ""));
3832        // A live check's lines, written before 0.7.6: handed over in place of a question.
3833        assert_eq!(queue_line(&format!("live A @{c}")), (Queued::Ask, vec!["A"], c));
3834        assert_eq!(queue_line("live A"), (Queued::Ask, vec!["A"], ""));
3835        assert_eq!(queue_line(&format!("live-next A,B @{c}")), (Queued::Ask, vec!["A", "B"], c));
3836        assert_eq!(listed(&format!("#borg-asked A,B @{c}\n#borg-asked C\n"), "#borg-asked "), ["A", "B", "C"].into_iter().collect());
3837    }
3838
3839    #[test]
3840    fn a_tagged_propensity_is_its_checks_never_another_checks() {
3841        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";
3842        assert_eq!(tagged_p(state, "#borg-pf ", "c1", &["A", "B"]), [1.0, 0.5]);
3843        assert_eq!(tagged_p(state, "#borg-pf ", "c2", &["A"]), [0.25]);
3844        assert!(tagged_p(state, "#borg-pf ", "c2", &["A", "B"]).is_empty(), "B has none at c2: no guess");
3845        assert_eq!(tagged_p(state, "#borg-p ", "c1", &["A"]), [0.75], "#borg-p and #borg-pf are separate lines");
3846        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"));
3847        assert_eq!(p_line("#borg-p", [], Some("c1")), None);
3848    }
3849
3850    #[test]
3851    fn unjudged_says_why_each_delivered_lesson_has_no_verdict() {
3852        let c = "0123456789ab";
3853        let state = format!(
3854            "#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\
3855             #borg-queued E @{c}\n#borg-judged B=pending\n#borg-asked D\n"
3856        );
3857        let why = unjudged(&state);
3858        assert_eq!(why["A"], outcome::UNJUDGED_TRIES, "two unclear tries");
3859        assert_eq!(why["B"], outcome::UNJUDGED_SLOTS, "one try is not all of them; the last call left it out");
3860        assert_eq!(why["C"], outcome::UNJUDGED_NO_CALL);
3861        assert_eq!(why["D"], outcome::UNJUDGED_NO_CALL, "delivered again after its failed call");
3862        assert_eq!(why["E"], outcome::UNJUDGED_NO_CALL);
3863    }
3864
3865    #[test]
3866    fn the_declaration_coin_is_fixed_per_check_and_lesson_and_fair() {
3867        assert_eq!(stripped("0123456789ab", "L1"), stripped("0123456789ab", "L1"));
3868        let heads = (0..1000).filter(|i| stripped("0123456789ab", &format!("L{i}"))).count();
3869        assert!((400..600).contains(&heads), "{heads} of 1000");
3870        let checks = (0..1000).filter(|i| stripped(&format!("{i:012x}"), "L1")).count();
3871        assert!((400..600).contains(&checks), "{checks} of 1000");
3872    }
3873
3874    /// Judge v2's evidence: a free delivery and a placebo get the work so far before them and the work
3875    /// since after, as a question does; each lesson carries the check that routed it (a stop's block from
3876    /// before 0.7.6 may carry a live check's waiting ask); a stripped declaration is gone from the shared
3877    /// evidence.
3878    #[test]
3879    fn pending_gives_every_route_the_same_evidence_and_its_own_check() {
3880        let session = format!("pending-v2-{}", std::process::id());
3881        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3882        let all = [c("A"), c("B"), c("C"), c("D"), c("E")];
3883        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\
3884                     edit one\n#borg-work the work so far\n#borg-queued C @0000000000c3\n#borg-placebo D @0000000000c4\nedit two\n\
3885                     #borg-work later work\n#borg-asked E @0000000000c5\n";
3886        let _ = std::fs::write(edits_file(&session), edits);
3887        let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
3888        let j = pending(&session, &state, &all, "the final message", When::Stop, "00000000cafe");
3889        let got: Vec<(&str, &str, &str)> = j.pending.iter().map(|p| (p.lesson.id.as_str(), p.channel, p.check.as_str())).collect();
3890        assert_eq!(
3891            got,
3892            [
3893                ("A", "stop", "0000000000c1"),
3894                ("B", "stop", "0000000000c2"),
3895                ("C", "queued", "0000000000c3"),
3896                ("D", outcome::PLACEBO, "0000000000c4"),
3897                ("E", "stop", "0000000000c5")
3898            ]
3899        );
3900        let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
3901        assert_eq!(
3902            (p("A").before.as_str(), p("C").before.as_str(), p("D").before.as_str()),
3903            ("Plan: the blocked message", "the work so far", "the work so far")
3904        );
3905        assert_eq!(p("C").after, "work since: edit two\nthis turn: the final message");
3906        assert_eq!(p("D").after, p("C").after, "a placebo is judged as a free delivery");
3907        assert!(p("A").after.starts_with("work since: ") && p("A").after.contains("edit one | edit two"), "{}", p("A").after);
3908        assert_eq!(p("A").after, p("B").after, "delivered together, one shared evidence");
3909        for (id, word) in [("A", "applies"), ("B", "skip")] {
3910            let x = p(id);
3911            assert_eq!(x.stripped, stripped(&x.check, id));
3912            match x.stripped {
3913                true => assert!(x.declared.is_none() && !x.after.contains(&format!("{id}: {word}")), "{x:?}"),
3914                false => assert!(x.declared.is_some() && x.after.contains(&format!("{id}: {word}")), "{x:?}"),
3915            }
3916        }
3917        assert!(!p("C").stripped && !p("D").stripped, "only questions are stripped");
3918        assert!(j.question && j.crowded.is_empty());
3919        let _ = std::fs::remove_file(edits_file(&session));
3920    }
3921
3922    /// History (live mode, before 0.7.6): a lesson handed over free at check A, then asked live at check B,
3923    /// in a session that runs across the upgrade. The live question still reads as channel live and is
3924    /// the lesson's latest delivery: the lesson is judged once, on the question, answered or not.
3925    #[test]
3926    fn a_live_question_from_before_the_upgrade_is_still_its_lessons_latest_delivery() {
3927        let session = format!("pending-later-{}", std::process::id());
3928        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3929        let all = [c("L"), c("M")];
3930        let (a, b) = ("00000000000a", "00000000000b");
3931        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");
3932        let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
3933        let answered = format!("{asked}#borg-answer L: applies\n");
3934        for edits in [&asked, &answered] {
3935            let _ = std::fs::write(edits_file(&session), edits);
3936            for when in [When::Stop, When::End] {
3937                let j = pending(&session, &state_of(edits), &all, "", when, "00000000cafe");
3938                let got: Vec<(String, &str, String)> = j.pending.iter().map(|p| (p.lesson.id.clone(), p.channel, p.check.clone())).collect();
3939                assert_eq!(got, [("M".to_string(), "queued", a.to_string()), ("L".to_string(), "live", b.to_string())], "{when:?}: {edits}");
3940            }
3941        }
3942        let _ = std::fs::remove_file(edits_file(&session));
3943    }
3944
3945    /// Judge timing (0.7.5): a stop's judge call judges a free delivery, an early one and a placebo, work
3946    /// after it or not, and so does the session-end judge.
3947    #[test]
3948    fn a_free_delivery_or_placebo_is_judged_at_the_turn_end() {
3949        let session = format!("pending-work-{}", std::process::id());
3950        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3951        let all = [c("A"), c("C"), c("D"), c("E")];
3952        let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
3953        let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
3954        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";
3955        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")] {
3956            let _ = std::fs::write(edits_file(&session), &edits);
3957            let state = state_of(&edits);
3958            let early = edits.contains("#borg-early ");
3959            for when in [When::Stop, When::End] {
3960                let want: &[&str] = if early { &["A", "C", "D", "E"] } else { &["A", "C", "D"] };
3961                assert_eq!(ids(&pending(&session, &state, &all, "", when, "00000000cafe")), want, "{when:?}: {edits}");
3962            }
3963        }
3964        let _ = std::fs::remove_file(edits_file(&session));
3965    }
3966
3967    /// The session-end judge (`When::End`): every delivery no verdict settled, a question not answered
3968    /// and a lesson whose check tries ran out included, never one a verdict settled; what it was shown
3969    /// (`END_JUDGED`) is settled for every later call, until the lesson is delivered again, and settles
3970    /// at `borg learn` as having used its tries.
3971    #[test]
3972    fn the_session_end_judge_never_outlives_its_lock() {
3973        let (end, stale) = (end_timeout(), live::stale_ms());
3974        assert!(i64::try_from(end.as_millis()).unwrap() < stale, "{end:?} against a lock stale after {stale} ms");
3975        assert!(end <= learn::learn_timeout());
3976    }
3977
3978    #[test]
3979    fn the_session_end_judge_takes_every_open_delivery_once() {
3980        let session = format!("pending-end-{}", std::process::id());
3981        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3982        let all = [c("A"), c("B"), c("C"), c("D"), c("Q")];
3983        let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
3984        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";
3985        let _ = std::fs::write(edits_file(&session), edits);
3986        let delivered: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
3987        // A changed, B unclear twice (tries spent), C unclear once, D not judged, Q never answered.
3988        let state = format!("{delivered}#borg-judged A=changed\n#borg-judged B=pending\n#borg-judged C=pending\n#borg-judged B=pending\n");
3989        assert_eq!(ids(&pending(&session, &state, &all, "", When::Stop, "00000000cafe")), ["C", "D", "Q"], "a check: B's tries are spent");
3990        assert_eq!(ids(&pending(&session, &state, &all, "", When::End, "00000000cafe")), ["B", "C", "D", "Q"], "the end: every open one");
3991        let ended = format!("{state}#borg-judged B=pending\n#borg-judged C=changed\n{END_JUDGED}B,C,D,Q\n");
3992        for when in [When::Stop, When::End] {
3993            assert!(pending(&session, &ended, &all, "", when, "00000000cafe").pending.is_empty(), "{when:?}: judged at the end once");
3994        }
3995        let why = unjudged(&ended);
3996        assert_eq!((why["B"], why["D"], why["Q"]), (outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES));
3997        // Delivered again after the end (a resumed session): open again.
3998        let again = format!("{ended}#borg-queued D @0000000000c5\n");
3999        let _ = std::fs::write(edits_file(&session), format!("{edits}#borg-queued D @0000000000c5\n"));
4000        assert_eq!(ids(&pending(&session, &again, &all, "", When::End, "00000000cafe")), ["D"]);
4001        assert_eq!(unjudged(&again)["D"], outcome::UNJUDGED_NO_CALL);
4002        let _ = std::fs::remove_file(edits_file(&session));
4003    }
4004
4005    /// A source of the session's work by its own text, `marks` where each delivery stood.
4006    fn source(label: &'static str, text: &str, marks: &[(usize, &str)]) -> Source {
4007        Source { label, text: text.chars().collect(), marks: marks.iter().map(|(at, id)| (*at, vec![id.to_string()])).collect() }
4008    }
4009
4010    /// The session's work for the session-end judge: each source labelled, whole when all fit; else the
4011    /// budget, framing included, shared so a short source leaves the rest to the longer ones, each cut
4012    /// its newest characters (`newest`, and wherever a source has no delivery in it).
4013    #[test]
4014    fn the_session_work_shares_its_budget_and_is_read_whole_when_it_fits() {
4015        let parts = [source("short", "abc", &[]), source("long", "0123456789", &[]), source("none", "", &[])];
4016        let none = [None, None, None];
4017        let whole = "### short\nabc\n### long\n0123456789\n";
4018        assert_eq!(within(&parts, &none, 100, Cut::After), (whole.to_string(), false));
4019        assert_eq!(within(&parts, &none, whole.chars().count(), Cut::After), (whole.to_string(), false), "exactly fits: whole");
4020        // Framing: `### short\n` + `\n` (11) and `### long\n` + `\n` (10), two marks each: 25 of 33 leave 8.
4021        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");
4022        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");
4023        for budget in 0..60 {
4024            for cut in [Cut::Newest, Cut::After, Cut::Ends] {
4025                let (text, _) = within(&parts, &[None, Some(3), None], budget, cut);
4026                assert!(text.chars().count() <= budget, "{budget} {cut:?}: {text:?}");
4027            }
4028        }
4029    }
4030
4031    /// A long session's work cut for a batch: `newest` its end; `after` from the batch's earliest
4032    /// delivery on, its start first; `ends` half from there, half the end; a source without the batch's
4033    /// delivery in it, and every cut when what follows the delivery fits, the newest.
4034    #[test]
4035    fn a_long_sessions_work_is_cut_at_the_batchs_earliest_delivery() {
4036        let text: Vec<char> = "0123456789abcdefghij".chars().collect();
4037        assert_eq!(Cut::Newest.window(&text, 6, Some(4)), "…efghij");
4038        assert_eq!(Cut::After.window(&text, 6, Some(4)), "…456789…", "from the delivery on, its start first");
4039        assert_eq!(Cut::After.window(&text, 6, Some(0)), "012345…");
4040        assert_eq!(Cut::Ends.window(&text, 6, Some(4)), "…456…hij", "half after the delivery, half the end");
4041        assert_eq!(Cut::After.window(&text, 6, Some(16)), "…efghij", "what follows fits: the newest");
4042        assert_eq!(Cut::Ends.window(&text, 6, Some(15)), "…efghij", "the halves meet: the newest");
4043        for cut in [Cut::Newest, Cut::After, Cut::Ends] {
4044            assert_eq!(cut.window(&text, 6, None), "…efghij", "{cut:?}: no delivery in it");
4045        }
4046        // Within a batch's budget: the edits part from the batch's delivery on, the messages their end.
4047        let parts = [source("edits", "0123456789abcdefghij", &[(4, "L")]), source("said", "ABCDEFGHIJKLMNOPQRST", &[])];
4048        let (work, cut) = within(&parts, &[parts[0].anchor(&["L"]), parts[1].anchor(&["L"])], 34, Cut::After);
4049        assert!(cut);
4050        assert_eq!(work, "### edits\n…4567…\n### said\n…PQRST\n");
4051    }
4052
4053    /// Where each delivery stood in a source: its latest marker naming the lesson (the delivery it is
4054    /// judged on), the earliest of a batch's; the markers and bookkeeping are no work, a stripped
4055    /// question's declaration is gone from it, an answer to a question is.
4056    #[test]
4057    fn a_source_marks_where_each_delivery_stood_without_stripped_declarations() {
4058        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4059        let (a, f) = (c("A"), c("F"));
4060        let p = |lesson, stripped| predict::Pending {
4061            lesson,
4062            before: String::new(),
4063            after: String::new(),
4064            declared: None,
4065            check: String::new(),
4066            channel: "stop",
4067            stripped,
4068        };
4069        let pend = [p(&a, false), p(&f, true)];
4070        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";
4071        let s = Source::of("edits", edits, |l| l.strip_prefix("#borg-answer ").or_else(|| (!l.starts_with("#borg-")).then_some(l)), &pend);
4072        let text: String = s.text.iter().collect();
4073        assert_eq!(text, "one\nA: applies. .\ntwo\nthree");
4074        assert_eq!((s.anchor(&["F"]), s.anchor(&["A"]), s.anchor(&["A", "F"]), s.anchor(&["Z"])), (Some(4), Some(22), Some(4), None));
4075        assert!(Source::of("blank", "#borg-asked A\n \n", Some, &pend).text.is_empty(), "only whitespace: empty");
4076    }
4077
4078    /// A session-end pass's budget is the judge's measured length (judge_length.rs), or the step above
4079    /// on the chance the estimate gives while that step is open: drawn per pass from its seed, the
4080    /// chance and the draw logged (`draw`); the same seed draws the same.
4081    #[test]
4082    fn a_pass_explores_the_step_above_on_its_logged_chance() {
4083        let d = std::env::temp_dir().join(format!("borg-end-budget-{}", std::process::id()));
4084        let _ = std::fs::remove_dir_all(&d);
4085        std::fs::create_dir_all(&d).unwrap();
4086        let draws: Vec<EndBudget> = (0..500).map(|i| end_budget(&d, "m:x", &format!("check-{i}"))).collect();
4087        let psi = crate::judge_length::EXPLORE_SHARE;
4088        for b in &draws {
4089            let explored = b.draw.expect("drawn, logged").1;
4090            assert_eq!(*b, EndBudget { chars: if explored { 108_000 } else { END_START_BUDGET }, draw: Some((psi, explored)) });
4091        }
4092        let share = draws.iter().filter(|b| b.draw.is_some_and(|d| d.1)).count() as f64 / draws.len() as f64;
4093        assert!((share - psi).abs() < 0.06, "explored {share} of passes at chance {psi}");
4094        assert_eq!(end_budget(&d, "m:x", "check-7"), draws[7], "the same seed, the same draw");
4095        // The step above decided as failing: explored at the floor's chance, logged.
4096        let line = |chars: usize, n: u64, a: u64| {
4097            format!("judge call=1 pending={n} resolved={a} chars={chars} verdicts=changed:{a},unclear:{} model=m:x at=end\n", n - a).repeat(10)
4098        };
4099        std::fs::write(d.join("s.cost"), line(30_000, 40, 20) + &line(100_000, 40, 5)).unwrap();
4100        let floor = crate::judge_length::FAILED_EXPLORE_SHARE;
4101        let draws: Vec<EndBudget> = (0..2000).map(|i| end_budget(&d, "m:x", &format!("check-{i}"))).collect();
4102        for b in &draws {
4103            let explored = b.draw.expect("drawn, logged").1;
4104            assert_eq!(*b, EndBudget { chars: if explored { 108_000 } else { END_START_BUDGET }, draw: Some((floor, explored)) });
4105        }
4106        let share = draws.iter().filter(|b| b.draw.is_some_and(|d| d.1)).count() as f64 / draws.len() as f64;
4107        assert!((share - floor).abs() < 0.02, "explored {share} of passes at chance {floor}");
4108        let _ = std::fs::remove_dir_all(&d);
4109    }
4110
4111    /// The session-end judge's batches: every open lesson in exactly one, in delivery order; each batch's
4112    /// lessons within half the budget (one alone may exceed it), and its whole prompt, with as much of a
4113    /// long session's work as is left, within the budget.
4114    #[test]
4115    fn every_lesson_lands_in_one_batch_whose_prompt_stays_within_the_budget() {
4116        let lessons: Vec<conscience::Challenge> = (0..278)
4117            .map(|i| conscience::Challenge {
4118                id: format!("L{i:03}"),
4119                question: format!("Question {i} {}", "q".repeat(i * 7 % 400)),
4120                consequence: format!("consequence {i}"),
4121                ..Default::default()
4122            })
4123            .collect();
4124        let pend: Vec<predict::Pending> = lessons
4125            .iter()
4126            .enumerate()
4127            .map(|(i, l)| predict::Pending {
4128                lesson: l,
4129                // Lessons delivered together share their evidence.
4130                before: format!("before {} {}", i / 3, "b".repeat(i * 13 % 1300)),
4131                after: format!("after {} {}", i / 3, "a".repeat(i * 31 % 4000)),
4132                declared: None,
4133                check: String::new(),
4134                channel: "queued",
4135                stripped: false,
4136            })
4137            .collect();
4138        let long: String = (0..4000).map(|i| format!("edit {i:04} of the long session\n")).collect();
4139        let marks: Vec<(usize, &str)> = lessons.iter().enumerate().map(|(i, l)| (i * 100, l.id.as_str())).collect();
4140        let sources = [source("prompts", &"p".repeat(30_000), &[]), source("edits", &long, &marks), source("said", "", &[])];
4141        for budget in [END_START_BUDGET, 20_000, 200_000] {
4142            let groups = batches(&pend, budget);
4143            assert!(groups.len() > 1, "{budget}: {groups:?}");
4144            assert_eq!(groups.iter().flat_map(Clone::clone).collect::<Vec<_>>(), (0..pend.len()).collect::<Vec<_>>(), "each once, in order");
4145            for g in &groups {
4146                let batch = &pend[g.clone()];
4147                assert!(batch.len() == 1 || predict::judge_section(batch).chars().count() <= budget / END_LESSONS_SHARE, "{budget}: {g:?}");
4148                let fixed = predict::judge_prompt("", batch).chars().count();
4149                let ids: Vec<&str> = batch.iter().map(|p| p.lesson.id.as_str()).collect();
4150                let anchors: Vec<Option<usize>> = sources.iter().map(|s| s.anchor(&ids)).collect();
4151                let (work, cut) = within(&sources, &anchors, budget.saturating_sub(fixed), Cut::After);
4152                assert!(cut, "a long session is cut");
4153                assert!(predict::judge_prompt(&work, batch).chars().count() <= budget, "{budget}: {g:?}");
4154                // `after`: the edits part starts at the batch's first delivery (or at the newest share).
4155                let first = g.start * 100;
4156                let body = work.split("### edits\n").nth(1).unwrap().trim_end_matches('\n');
4157                let body = body.strip_prefix('…').unwrap_or(body).trim_end_matches('…');
4158                let at = long.find(body).unwrap();
4159                assert!(at == first || (at <= first && at + body.len() + 1 >= long.len()), "{budget}: {g:?} at {at}");
4160            }
4161            // A lesson whose evidence alone exceeds half the budget goes alone.
4162            assert!(batches(&pend[..3], 100).iter().all(|g| g.len() == 1));
4163        }
4164    }
4165
4166    /// The judge's cost line names the ids the answer returned and those that matched no pending lesson,
4167    /// after the fields `borg status` sums (prefilter.rs `tally`).
4168    #[test]
4169    fn the_judge_cost_line_names_the_returned_and_unmatched_ids() {
4170        fn p(lesson: &conscience::Challenge) -> predict::Pending<'_> {
4171            predict::Pending { lesson, before: String::new(), after: String::new(), declared: None, check: String::new(), channel: "stop", stripped: false }
4172        }
4173        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4174        let (a, b) = (c("La"), c("Lb"));
4175        let pend = [p(&a), p(&b)];
4176        let returned = ["La".to_string(), "Lz".to_string()];
4177        let j = |id: &str, verdict, reason: &str| predict::Judged { id: id.into(), verdict, reason: reason.into() };
4178        let one = [j("La", predict::Verdict::NotArisen, "only docs"), j("Lz", predict::Verdict::Changed, "not pending")];
4179        assert_eq!(
4180            judge_line(3, &pend, &one, 900, &returned),
4181            "judge call=3 pending=2 resolved=1 chars=900 returned=La,Lz unmatched=Lz \
4182             verdicts=changed:0,ignored:0,not_arisen:1,consequence_remains:0,unclear:0 reason_chars=9"
4183        );
4184        let pair = [j("La", predict::Verdict::Unclear, ""), j("Lb", predict::Verdict::ConsequenceRemains, "still awaits")];
4185        assert_eq!(
4186            judge_line(4, &pend, &pair, 10, &[]),
4187            "judge call=4 pending=2 resolved=0 chars=10 returned= unmatched= \
4188             verdicts=changed:0,ignored:0,not_arisen:0,consequence_remains:1,unclear:1 reason_chars=12"
4189        );
4190    }
4191
4192    /// A live question answered at the stop: the stop's own message is the answer, so a stripped
4193    /// question's declaration must leave "this turn" too; and when that stop blocks with a new question,
4194    /// its blocked message (that question's work before, and work since for the live one) as well. A
4195    /// question that keeps its declaration keeps it.
4196    #[test]
4197    fn a_stripped_declaration_leaves_every_evidence_of_the_call() {
4198        let session = format!("pending-strip-{}", std::process::id());
4199        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4200        let all = [c("A"), c("F"), c("G")];
4201        let check = |lesson: &str, strip: bool| (0u64..).map(|i| format!("{i:012x}")).find(|k| stripped(k, lesson) == strip).unwrap();
4202        let (ca, cf, cg) = (check("A", false), check("F", true), check("G", false));
4203        let answer = "Fixed the arc. F: applies";
4204        let edits = format!(
4205            "Plan: the blocked message\n#borg-asked A @{ca}\n#borg-answer A: applies\nedit one\n#borg-work the work so far\n\
4206             #borg-asked F @{cf}\n#borg-live F ms=1\n#borg-answer {answer}\n{answer}\n#borg-asked G @{cg}\n"
4207        );
4208        let _ = std::fs::write(edits_file(&session), &edits);
4209        let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
4210        let j = pending(&session, &state, &all, answer, When::Stop, "00000000cafe");
4211        let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
4212        assert!(p("F").stripped && p("F").declared.is_none(), "{:?}", p("F"));
4213        for x in &j.pending {
4214            assert!(!format!("{} {}", x.before, x.after).to_lowercase().contains("f: applies"), "{x:?}");
4215        }
4216        assert!(p("F").after.contains("this turn: Fixed the arc."), "the rest of the message stays: {}", p("F").after);
4217        assert!(!p("A").stripped && p("A").declared == Some(true) && p("A").after.contains("A: applies"), "{:?}", p("A"));
4218        // The work a call shows loses it too.
4219        assert_eq!(without_declarations(&format!("work\n{answer}"), &j.pending), "work\nFixed the arc. ");
4220        assert_eq!(without_declarations("A: applies", &j.pending), "A: applies");
4221        let _ = std::fs::remove_file(edits_file(&session));
4222    }
4223
4224    /// Track R's merged ask: a live check drew A and B from one pool, a stop check drew A again from
4225    /// another, and the stop's block holds the waiting B beside its A. The block's `asked` records are
4226    /// one per routing check (`stop_ask`), each with its own draw's pool; ids of two draws together
4227    /// carry none (counted as no_pool by `borg debug ope`), never one draw's pool with both p.
4228    #[test]
4229    fn a_merged_ask_of_two_draws_carries_no_pool() {
4230        let session = format!("pool-merge-{}", std::process::id());
4231        let _ = std::fs::remove_file(state_file(&session));
4232        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4233        let (a, b, cc, d, e, f) = (c("A"), c("B"), c("C"), c("D"), c("E"), c("F"));
4234        let live: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &b), (0.8, &cc), (0.7, &d)];
4235        append_p_of(&session, &live, &[&a, &b], "c1");
4236        let stop: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &e), (0.8, &f)];
4237        append_p_of(&session, &stop, &[&a], "c2");
4238        let state = std::fs::read_to_string(state_file(&session)).unwrap_or_default();
4239        let merged = vec!["A".to_string(), "B".to_string()];
4240        assert_eq!(propensity_in(&state, &merged).len(), 2, "each id has its latest p");
4241        assert_eq!(pool_in(&state, &merged), Default::default());
4242        assert_eq!(pool_in(&state, &["A".to_string()]).0, ["A", "E", "F"], "A alone: the stop check's pool");
4243        assert_eq!(pool_in(&state, &["B".to_string()]).0, ["A", "B", "C", "D"], "B alone: the live check's");
4244        assert_eq!(tagged_p(&state, "#borg-p ", "c1", &["B"]).len(), 1, "B's record carries its own check's p");
4245        let _ = std::fs::remove_file(state_file(&session));
4246    }
4247
4248    /// An early delivery's relative scores (`early_rel`): each lesson's match as a share of the best
4249    /// on-topic match, which a lesson done already may hold; a standing lesson's on the same scale (0
4250    /// without a word in common); nothing to scale by without a plain lesson on the topic.
4251    #[test]
4252    fn an_early_lessons_relative_score_is_its_share_of_the_best_on_topic_match() {
4253        let c = |id: &str, q: &str| conscience::Challenge { id: id.into(), question: q.into(), scope: vec!["Orion".into()], ..Default::default() };
4254        let standing = conscience::Challenge { source: conscience::CONSTRAINT.into(), ..c("S", "harpsichord tuning") };
4255        let all = vec![c("A", "orion budget place reset arc drain"), c("B", "orion budget place"), c("C", "telescope glaciers"), standing];
4256        let work = "drain the orion budget place, never with a reset arc";
4257        let topics = vec!["Orion".to_string()];
4258        let ranked = conscience::rank(&all, work);
4259        let score = |id: &str| ranked.iter().find(|(_, x)| x.id == id).map_or(0.0, |(s, _)| *s);
4260        assert!(score("A") > score("B") && score("B") > 0.0, "{ranked:?}");
4261        // A, the best match, was delivered before (done): B's share is of A's score all the same.
4262        let rel = early_rel(&all, work, &topics, &[&all[1], &all[3]]);
4263        assert_eq!(rel, [outcome::round_p(score("B") / score("A")), 0.0]);
4264        assert!(rel[0] < 1.0);
4265        assert_eq!(early_rel(&all, work, &topics, &[&all[0]]), [1.0]);
4266        // Only a standing lesson on a topic no plain lesson matches: nothing to scale by.
4267        assert!(early_rel(&all, work, &["Beacon".to_string()], &[&all[3]]).is_empty());
4268    }
4269
4270    /// A store for `decide`: its lessons file (empty) and outcomes beside it, and a saved selection summary
4271    /// whose fit is sure that handing over beats asking, with a gate open wide: a learned live check
4272    /// deviates on evidence, and its record moves the gate's ledger.
4273    fn decide_store(name: &str) -> std::path::PathBuf {
4274        decide_store_with(name, true)
4275    }
4276
4277    /// `decide_store`, its fit `sure` that handing over beats asking, or (not `sure`) with no evidence
4278    /// either way, asking and handing over worth the same: a learned live check then keeps the fixed rule
4279    /// on evidence and runs the question-count test, which the open gate admits.
4280    fn decide_store_with(name: &str, sure: bool) -> std::path::PathBuf {
4281        use crate::select::{policy, summary};
4282        let d = std::env::temp_dir().join(format!("borg-decide-{name}-{}", std::process::id()));
4283        let _ = std::fs::remove_dir_all(&d);
4284        std::fs::create_dir_all(&d).unwrap();
4285        let lessons = d.join("lessons.jsonl");
4286        std::fs::write(&lessons, "").unwrap();
4287        std::fs::write(outcome::path_beside(&lessons), "").unwrap();
4288        let at = |name: &str| policy::NAMES.iter().position(|n| *n == name).unwrap();
4289        let mut beta = vec![0.0; policy::P];
4290        beta[at("intercept")] = -0.5;
4291        if sure {
4292            beta[at("ask")] = -3.0;
4293            beta[at("not")] = -1.0;
4294        }
4295        let mut chol = vec![0.0; policy::P * policy::P];
4296        for k in 0..policy::P {
4297            chol[k * policy::P + k] = 0.05;
4298        }
4299        let model = policy::Model { beta, chol, sd: vec![1.0; policy::P], spread: [0.1; 3], ..policy::Model::default() };
4300        let rates = policy::Rates { free_stop: 1.0, free_live: 1.0, later: 0.0, wait: 0.0, ask_live: 1.0 };
4301        let fit = policy::Fit { key: "9-abcdef01".into(), model: Some(model), rates, ..policy::Fit::default() };
4302        let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: 5.0, ..summary::Gate::default() };
4303        summary::save(&lessons, &summary::Summary { fit, gate, ..summary::Summary::default() }).unwrap();
4304        lessons
4305    }
4306
4307    /// `record` for `decide` in a test: the hook's own graded record (`graded_outcome`), beside `lessons`;
4308    /// whether it landed, as `record_graded` answers.
4309    fn record_beside(lessons: &std::path::Path, applied: &[(&conscience::Challenge, u8)], check: &str, r: &crate::select::Routed, basis: &Basis) -> bool {
4310        let order: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
4311        let call = Call {
4312            eligible: order.len(),
4313            order: &order,
4314            tail: &[],
4315            pt: 0.0,
4316            boundary: &[],
4317            budget: 0,
4318            model: "",
4319            prompt_hash: String::new(),
4320            dur_ms: 1,
4321            usd: 0.0,
4322            line: "",
4323            moves: Vec::new(),
4324            whys: Vec::new(),
4325        };
4326        let o = graded_outcome("decide-s", "r", check, applied, Some((r, basis)), basis.summary, &call);
4327        outcome::append(&outcome::path_beside(lessons), &o).is_ok()
4328    }
4329
4330    /// The question-count test's k and s reach the graded record from the decision's log, the s = 0
4331    /// draw included; a check outside the test and a failed one log neither.
4332    #[test]
4333    fn the_graded_record_carries_the_question_count_tests_draw() {
4334        use crate::select::{Log, Routed, policy, summary};
4335        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4336        let ls = [c("A"), c("B")];
4337        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([2, 1]).collect();
4338        let order: Vec<&conscience::Challenge> = ls.iter().collect();
4339        let call = Call {
4340            eligible: 2,
4341            order: &order,
4342            tail: &[],
4343            pt: 0.0,
4344            boundary: &[],
4345            budget: 16_000,
4346            model: "",
4347            prompt_hash: String::new(),
4348            dur_ms: 1,
4349            usd: 0.0,
4350            line: "",
4351            moves: Vec::new(),
4352            whys: Vec::new(),
4353        };
4354        let s = summary::Summary::default();
4355        let basis = Basis { summary: &s, had: &[], why: "" };
4356        let routed = |source: &'static str, xk: Option<u32>, xs: Option<u32>| Routed {
4357            ask: Vec::new(),
4358            free: vec![&ls[0], &ls[1]],
4359            placebo: Vec::new(),
4360            p: vec![0.0, 0.0],
4361            pf: vec![1.0, 1.0],
4362            log: Log { policy: "learned", source, xk, xs, ..Log::default() },
4363        };
4364        let record = |r: Option<&Routed>| graded_outcome("s", "r", "0123456789ab", &applied, r.map(|r| (r, &basis)), &s, &call);
4365        let drawn = record(Some(&routed(policy::EXPERIMENT_CONTROL, Some(2), Some(0))));
4366        assert_eq!((drawn.source.as_str(), drawn.xk, drawn.xs), (policy::EXPERIMENT_CONTROL, Some(2), Some(0)));
4367        let outside = record(Some(&routed(policy::EVIDENCE, None, None)));
4368        assert_eq!((outside.source.as_str(), outside.xk, outside.xs), (policy::EVIDENCE, None, None));
4369        let failed = record(None);
4370        assert_eq!((failed.failed, failed.xk, failed.xs), (true, None, None));
4371        // The pool budget the check filled to, whatever it decided.
4372        assert_eq!((drawn.budget, failed.budget), (16_000, 16_000));
4373    }
4374
4375    fn graded_records(lessons: &std::path::Path) -> Vec<outcome::Outcome> {
4376        outcome::read(&outcome::path_beside(lessons)).into_iter().filter(|o| o.kind == outcome::GRADED).collect()
4377    }
4378
4379    /// A check decides on the summary it loads under the store's decision lock, never on the one it read
4380    /// at its start: B started before A decided, yet B's decision reads A's ledger entry and says so.
4381    #[test]
4382    fn a_check_decides_on_the_summary_under_the_lock_with_every_decision_before_it() {
4383        use crate::select::{Policy, policy, summary};
4384        let lessons = decide_store("fresh");
4385        // What both checks read at their start, before either decided.
4386        let stale = summary::refresh(&lessons);
4387        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4388        let ls = [c("A"), c("B"), c("C"), c("D")];
4389        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
4390        let mut gates = Vec::new();
4391        // One after the other, with the hook's own wait: a process spawned by another test while the
4392        // first held the lock keeps a copy of its descriptor, so the flock, until it execs.
4393        for check in ["0000000000a1", "0000000000b2"] {
4394            let at = Decide { session: "decide-fresh", check, channel: "live", lessons: &lessons, tokens: 0, recorded: true };
4395            let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |r, b| record_beside(&lessons, &applied, check, r, b));
4396            gates.push(r.log.gate);
4397        }
4398        let g = graded_records(&lessons);
4399        assert_eq!(g.len(), 2);
4400        assert!(g.iter().all(|o| o.policy == "learned" && o.reason.is_empty()), "{g:?}");
4401        assert_ne!(g[0].free, Vec::<String>::new(), "the first deviates: everything free at a live check, {:?}", g[0]);
4402        assert_eq!(g[0].seen, stale.key(), "nothing was decided before the first");
4403        // The second's summary is the start's with the first's record folded in, its ledger entry too.
4404        let log = std::fs::read_to_string(outcome::path_beside(&lessons)).unwrap();
4405        let first = log.lines().next().unwrap();
4406        let after = summary::fold(&format!("{first}\n"), stale.clone());
4407        assert_eq!(after.gate.checks, stale.gate.checks + 1, "the first's decision is in the gate's ledger");
4408        assert_eq!(g[1].seen, after.key(), "decided on the summary loaded under the lock, not the one read at its start");
4409        assert_eq!(gates[1], policy::remaining(&after.gate).max(0.0), "against the gate with the first's entry");
4410        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4411    }
4412
4413    /// Two checks deciding at once take turns: while one holds the store's decision lock (deciding and
4414    /// recording), the other, past its wait, takes the fixed rule, which reads no gate, and says why;
4415    /// never both against the same gate. So for a deviation on evidence and for the question-count test
4416    /// alike (its draw logged on the learned record only). A lock that cannot be taken at all is the
4417    /// fixed rule too.
4418    #[test]
4419    fn two_checks_deciding_at_once_never_both_take_the_gate() {
4420        use crate::select::{Policy, policy, summary};
4421        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4422        let ls = [c("A"), c("B"), c("C"), c("D")];
4423        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
4424        let mut stores = Vec::new();
4425        for (name, sure, sources) in [("race", true, &[policy::EVIDENCE][..]), ("race-test", false, &[policy::EXPERIMENT, policy::EXPERIMENT_CONTROL][..])] {
4426            let lessons = decide_store_with(name, sure);
4427            let stale = summary::refresh(&lessons);
4428            // Each records only once both have decided: the first to take the lock holds it until the
4429            // other decided without it.
4430            let both = std::sync::Barrier::new(2);
4431            std::thread::scope(|s| {
4432                for check in ["0000000000c1", "0000000000c2"] {
4433                    let (applied, lessons, stale, both) = (&applied, &lessons, &stale, &both);
4434                    s.spawn(move || {
4435                        let at = Decide { session: "decide-race", check, channel: "live", lessons, tokens: 0, recorded: true };
4436                        decide(Policy::Learned, applied, &at, stale, std::time::Duration::ZERO, |r, b| {
4437                            both.wait();
4438                            record_beside(lessons, applied, check, r, b)
4439                        });
4440                    });
4441                }
4442            });
4443            let mut got: Vec<(String, String, String, bool)> =
4444                graded_records(&lessons).into_iter().map(|o| (o.policy, o.source, o.reason, o.xk.is_some())).collect();
4445            got.sort();
4446            assert_eq!(got.len(), 2, "{name}: {got:?}");
4447            assert_eq!(got[0], ("fixed".to_string(), "fixed".to_string(), outcome::DECIDED_BUSY.to_string(), false), "{name}: the other took the fixed rule");
4448            let (policy, source, reason, drawn) = &got[1];
4449            assert!(policy == "learned" && sources.contains(&source.as_str()) && reason.is_empty(), "{name}: one decided under the lock: {got:?}");
4450            assert_eq!(*drawn, !sure, "{name}: the test's draw on its record alone");
4451            assert!(policy::remaining(&summary::refresh(&lessons).gate) >= -1e-9, "{name}: the gate never overspent");
4452            stores.push(lessons);
4453        }
4454        // No lock to be had (the store's directory is gone): the fixed rule, and why.
4455        let stale = summary::refresh(&stores[0]);
4456        let gone = std::path::Path::new("/nonexistent/borg-decide/lessons.jsonl");
4457        let at = Decide { session: "decide-race", check: "0000000000c3", channel: "live", lessons: gone, tokens: 0, recorded: true };
4458        let mut why = "";
4459        let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |_, b| {
4460            why = b.why;
4461            true
4462        });
4463        assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_NO_LOCK));
4464        for lessons in stores {
4465            let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4466        }
4467    }
4468
4469    /// The fixed rule and the evaluation switches read no gate: while another check holds the store's
4470    /// decision lock, each decides by itself at once, on the summary its check read at its start, and its
4471    /// record gives no reason; only a learned check waits for the lock or falls back.
4472    #[test]
4473    fn a_policy_that_reads_no_gate_decides_by_itself_whoever_holds_the_lock() {
4474        use crate::select::{Ctx, Policy, graded_under, summary};
4475        let lessons = decide_store("nolock");
4476        let stale = summary::refresh(&lessons);
4477        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4478        let ls = [c("A"), c("B"), c("C"), c("D")];
4479        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 3, 2]).collect();
4480        let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
4481        let held = lock_within(&decide_lock(&lessons), std::time::Duration::ZERO).unwrap();
4482        let check = "0000000000e1";
4483        let at = Decide { session: "decide-nolock", check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
4484        let ctx = Ctx {
4485            session: at.session,
4486            check,
4487            channel: at.channel,
4488            lessons: &lessons,
4489            summary: &stale,
4490            tokens: 0,
4491            had: &[],
4492            alpha: crate::select::alpha(&lessons),
4493        };
4494        let fixed = ids(&graded_under(Policy::Fixed, &applied, &ctx).ask);
4495        for policy in [Policy::Fixed, Policy::Cap(1), Policy::Attention] {
4496            let own = graded_under(policy, &applied, &ctx);
4497            let mut got = None;
4498            let r = decide(policy, &applied, &at, &stale, std::time::Duration::ZERO, |r, b| {
4499                got = Some((r.log.policy, b.why, b.summary.key()));
4500                true
4501            });
4502            assert_eq!(got, Some((policy.name(), "", stale.key())), "{policy:?}");
4503            assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&own.ask), ids(&own.free)), "{policy:?}: its own rule");
4504        }
4505        // Not vacuous: the cap's block differs from the fixed rule's, which a fallback would have asked.
4506        assert_ne!(ids(&graded_under(Policy::Cap(1), &applied, &ctx).ask), fixed);
4507        let mut why = "";
4508        let r = decide(Policy::Learned, &applied, &at, &stale, std::time::Duration::ZERO, |_, b| {
4509            why = b.why;
4510            true
4511        });
4512        assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_BUSY), "the learned policy alone waits for the lock");
4513        drop(held);
4514        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4515    }
4516
4517    /// A learned decision is carried out only once its record landed in the log its gate is folded from,
4518    /// the gate's only debit. One whose record would not land there (learning off, the lessons of
4519    /// another store: `Decide::recorded`) or did not (a failed append) takes the fixed rule and says why,
4520    /// so checks one after another never each risk the whole allowance of a gate none of them debited.
4521    #[test]
4522    fn a_learned_decision_whose_record_cannot_land_takes_the_fixed_rule() {
4523        use crate::select::{Ctx, Policy, graded_under, policy, summary};
4524        let lessons = decide_store("unrecorded");
4525        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4526        let ls = [c("A"), c("B"), c("C"), c("D")];
4527        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
4528        let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
4529        let at = |check: &'static str, recorded: bool| Decide { session: "decide-unrecorded", check, channel: "live", lessons: &lessons, tokens: 0, recorded };
4530        let fixed = |check: &str, s: &summary::Summary| {
4531            let ctx = Ctx {
4532                session: "decide-unrecorded",
4533                check,
4534                channel: "live",
4535                lessons: &lessons,
4536                summary: s,
4537                tokens: 0,
4538                had: &[],
4539                alpha: crate::select::alpha(&lessons),
4540            };
4541            let r = graded_under(Policy::Fixed, &applied, &ctx);
4542            (ids(&r.ask), ids(&r.free), ids(&r.placebo))
4543        };
4544        // Its record lands: the learned policy deviates (not vacuous).
4545        let stale = summary::refresh(&lessons);
4546        let r = decide(Policy::Learned, &applied, &at("0000000000f1", true), &stale, std::time::Duration::ZERO, |r, b| {
4547            record_beside(&lessons, &applied, "0000000000f1", r, b)
4548        });
4549        assert_eq!(r.log.source, policy::EVIDENCE, "{:?}", r.log);
4550        let (ask, free, _) = fixed("0000000000f1", &stale);
4551        assert_ne!((ids(&r.ask), ids(&r.free)), (ask, free), "it deviated");
4552        let gate = summary::refresh(&lessons).gate;
4553        // Two checks whose records would not land, one after the other: both the fixed rule, never tried.
4554        for check in ["0000000000f2", "0000000000f3"] {
4555            let stale = summary::refresh(&lessons);
4556            let mut tried = Vec::new();
4557            let r = decide(Policy::Learned, &applied, &at(check, false), &stale, std::time::Duration::ZERO, |r, b| {
4558                tried.push((r.log.policy, b.why));
4559                false
4560            });
4561            assert_eq!(tried, [("fixed", outcome::DECIDED_NO_RECORD)], "{check}");
4562            assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed(check, &stale), "{check}");
4563        }
4564        // A check whose record failed to append: decided again by the fixed rule under the lock, and that
4565        // carried out; the store's log and its gate are as they were.
4566        let stale = summary::refresh(&lessons);
4567        let mut tried = Vec::new();
4568        // After the first check's lock, so with the hook's wait (as in the test above).
4569        let r = decide(Policy::Learned, &applied, &at("0000000000f4", true), &stale, DECIDE_WAIT, |r, b| {
4570            tried.push((r.log.policy, b.why));
4571            false
4572        });
4573        assert_eq!(tried, [("learned", ""), ("fixed", outcome::DECIDED_NO_RECORD)]);
4574        assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed("0000000000f4", &stale));
4575        assert_eq!(graded_records(&lessons).len(), 1, "only the first check's record");
4576        assert_eq!(summary::refresh(&lessons).gate, gate, "no unrecorded decision moved the gate");
4577        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4578    }
4579
4580    /// A lesson another check queued to be asked or handed over, not delivered yet, is had at a decision
4581    /// as a delivered one is (`waiting_in`): it never takes a free slot (the next lesson does) nor a
4582    /// placebo's, and the record names it (`nofree`), so delivery never drops a second copy as queued
4583    /// twice. A lesson waiting as a placebo was routed nowhere: it may still go free.
4584    #[test]
4585    fn a_lesson_waiting_in_the_queue_never_takes_a_free_or_placebo_slot() {
4586        use crate::select::{Policy, summary};
4587        let lessons = decide_store("queued");
4588        let stale = summary::refresh(&lessons);
4589        let session = format!("decide-queued-{}", std::process::id());
4590        let _ = std::fs::remove_file(state_file(&session));
4591        std::fs::write(queued_file(&session), "X @0000000000d0\nlive Y @0000000000d0\nplacebo Z @0000000000d0\n").unwrap();
4592        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
4593        let named: Vec<String> = (1..=10).map(|i| format!("L{i:02}")).collect();
4594        let ls: Vec<conscience::Challenge> = ["X", "Y", "Z"].iter().map(|i| c(i)).chain(named.iter().map(|i| c(i))).collect();
4595        let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
4596        // No 3, so the fixed rule hands over up to `CAP`: X and Y first in line (the slots they would take
4597        // go to the next), or last, past ten 2s, where a placebo is drawn among what is left.
4598        let first: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if ["X", "Y"].contains(&c.id.as_str()) { 2 } else { 1 })).collect();
4599        let last: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if c.id.starts_with('L') { 2 } else { 1 })).collect();
4600        let waits = |l: &[String]| l.iter().any(|i| i == "X" || i == "Y");
4601        for (check, applied) in [("0000000000d1", &first), ("0000000000d2", &last)] {
4602            let at = Decide { session: &session, check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
4603            let r = decide(Policy::Fixed, applied, &at, &stale, std::time::Duration::ZERO, |r, b| record_beside(&lessons, applied, check, r, b));
4604            let (free, placebo) = (ids(&r.free), ids(&r.placebo));
4605            assert_eq!(free.len(), conscience::CAP, "{check}: every free slot taken: {free:?}");
4606            assert!(!waits(&free) && !waits(&placebo), "{check}: free {free:?}, placebo {placebo:?}");
4607        }
4608        assert_eq!(graded_records(&lessons)[1].placebo, ["Z"], "waiting as a placebo, Z is still drawn");
4609        assert!(graded_records(&lessons)[0].free.contains(&"Z".to_string()), "and still goes free");
4610        let g = graded_records(&lessons);
4611        assert_eq!(g.iter().map(|o| o.nofree.clone()).collect::<Vec<_>>(), [["X", "Y"], ["X", "Y"]], "{g:?}");
4612        let _ = std::fs::remove_file(queued_file(&session));
4613        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
4614    }
4615
4616    #[test]
4617    fn only_an_adapter_reads_which_messages_an_answer_showed() {
4618        let out = json!({"hookSpecificOutput": {"additionalContext": "[borg] Message from s1: hi"}, "shown_ids": ["m0123456789ab"]});
4619        for name in ["claude", "codex"] {
4620            let h = harness::by_name(name).unwrap();
4621            assert_eq!(for_harness(h, out.clone()), json!({"hookSpecificOutput": out["hookSpecificOutput"]}), "{name}");
4622        }
4623        for name in ["omp", "opencode"] {
4624            assert_eq!(for_harness(harness::by_name(name).unwrap(), out.clone()), out, "{name}");
4625        }
4626    }
4627}
4628
4629#[cfg(test)]
4630mod acted_since_tests {
4631    use super::*;
4632
4633    /// `early_acted` as 0.7.7 computed it, the oracle: every delivery reads the session before and after
4634    /// it whole.
4635    fn acted_scan(state: &str, edits: &str, all: &[conscience::Challenge], final_msg: &str) -> std::collections::HashSet<String> {
4636        let lines: Vec<&str> = edits.lines().filter(|l| !l.starts_with(WORK)).collect();
4637        let mut out = std::collections::HashSet::new();
4638        for (p, l) in lines.iter().enumerate() {
4639            let Some(list) = l.strip_prefix("#borg-early ").or_else(|| l.strip_prefix("#borg-queued ")) else { continue };
4640            let ids: std::collections::HashSet<&str> = ids_in(list).collect();
4641            let asked: Vec<&conscience::Challenge> = all.iter().filter(|c| ids.contains(c.id.as_str())).collect();
4642            let after: Vec<&str> = lines[p + 1..].iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
4643            let upto = state.find(&format!("{l}\n")).map_or(state, |i| &state[..i]);
4644            let before = format!("{upto}\n{}", lines[..p].join("\n"));
4645            out.extend(crate::memory::resolve(&asked, all, &before, &after.join("\n"), final_msg, crate::memory::NOVEL_WORDS).0);
4646        }
4647        out
4648    }
4649
4650    fn pick<'a>(r: &mut crate::memory::Rng, xs: &[&'a str]) -> &'a str {
4651        xs[(r.next_u64() % xs.len() as u64) as usize]
4652    }
4653
4654    /// The index equals the scan on random sessions: camelCase and non-ASCII words, delivery lines found
4655    /// in the state file, missing, repeated and mid-line, `#borg-work` and other bookkeeping lines, ids
4656    /// unknown to the store, declarations in the final message, and every cut of the .edits file a
4657    /// detached check reads (`edits_upto`).
4658    #[test]
4659    fn the_index_equals_the_scan_on_random_sessions() {
4660        let vocab = [
4661            "budget",
4662            "place",
4663            "drain",
4664            "resetArc",
4665            "token",
4666            "transition",
4667            "inhibitor",
4668            "arc",
4669            "netBudget",
4670            "ÜberPlace",
4671            "straße",
4672            "ΣIGMA",
4673            "fork_join",
4674            "subnet",
4675            "deadlock",
4676            "x",
4677            "go",
4678            "HTTPServer",
4679            "parseJSONLine",
4680            "retry",
4681            "bound",
4682            "guard",
4683            "Place",
4684            "PLACE",
4685            "café",
4686            "L1",
4687            "early",
4688        ];
4689        let mut acted = 0;
4690        for seed in 0..3000u64 {
4691            let mut r = crate::memory::Rng::new(seed);
4692            let nl = 3 + (r.next_u64() % 8) as usize;
4693            let all: Vec<conscience::Challenge> = (0..nl)
4694                .map(|k| conscience::Challenge {
4695                    id: format!("L{k}"),
4696                    question: (0..(4 + r.next_u64() % 10)).map(|_| pick(&mut r, &vocab)).collect::<Vec<_>>().join(" "),
4697                    ..Default::default()
4698                })
4699                .collect();
4700            let text = |r: &mut crate::memory::Rng| {
4701                let sep = if r.next_u64().is_multiple_of(2) { " " } else { "-" };
4702                (0..(r.next_u64() % 9)).map(|_| pick(r, &vocab)).collect::<Vec<_>>().join(sep)
4703            };
4704            let (mut edits, mut state) = (String::new(), String::new());
4705            for _ in 0..(r.next_u64() % 30) {
4706                let line = match r.next_u64() % 9 {
4707                    0 | 1 => {
4708                        let ids: Vec<String> = (0..1 + r.next_u64() % 3).map(|_| format!("L{}", r.next_u64() % (nl as u64 + 1))).collect();
4709                        let marker = if r.next_u64().is_multiple_of(2) { "#borg-early " } else { "#borg-queued " };
4710                        let check = if r.next_u64().is_multiple_of(2) { format!(" @c{}", r.next_u64() % 3) } else { String::new() };
4711                        format!("{marker}{}{check}", ids.join(","))
4712                    }
4713                    2 => format!("{WORK}{}", text(&mut r)),
4714                    3 => format!("#borg-answer {}", text(&mut r)),
4715                    _ => text(&mut r),
4716                };
4717                match r.next_u64() % 6 {
4718                    0 => {}
4719                    1 => state.push_str(&format!("{} {line}\n", text(&mut r))),
4720                    2 => state.push_str(&format!("{line}\n{}\n{line}\n", text(&mut r))),
4721                    _ => state.push_str(&format!("{line}\n")),
4722                }
4723                state.push_str(&text(&mut r));
4724                state.push('\n');
4725                edits.push_str(&line);
4726                edits.push('\n');
4727            }
4728            let mut fin = text(&mut r);
4729            if r.next_u64().is_multiple_of(3) {
4730                let verdict = if r.next_u64().is_multiple_of(2) { "applies" } else { "skip" };
4731                fin.push_str(&format!("\nL{}: {verdict}", r.next_u64() % nl as u64));
4732            }
4733            let want = acted_scan(&state, &edits, &all, &fin);
4734            assert_eq!(acted_since(&state, &edits, &all, &fin), want, "seed {seed}\nstate:\n{state}\nedits:\n{edits}\nfinal: {fin}");
4735            let cut = (r.next_u64() as usize) % (edits.len() + 1);
4736            if edits.is_char_boundary(cut) {
4737                assert_eq!(acted_since(&state, &edits[..cut], &all, ""), acted_scan(&state, &edits[..cut], &all, ""), "seed {seed} cut {cut}");
4738            }
4739            acted += usize::from(!want.is_empty());
4740        }
4741        assert!(acted > 300, "only {acted} sessions with a lesson acted on");
4742    }
4743
4744    /// On a real session (an ignored test, it reads files outside the repo): BORG_ACTED_STATE, a state
4745    /// dir; BORG_ACTED_SESSION, a session id in it; BORG_ACTED_LESSONS, its store's lessons file. Equal for
4746    /// several final messages and at 40 cuts of the .edits file.
4747    #[test]
4748    #[ignore]
4749    fn the_index_equals_the_scan_on_a_real_session() {
4750        let (Some(dir), Some(s), Some(lessons)) =
4751            (std::env::var_os("BORG_ACTED_STATE"), std::env::var("BORG_ACTED_SESSION").ok(), std::env::var_os("BORG_ACTED_LESSONS"))
4752        else {
4753            panic!("set BORG_ACTED_STATE, BORG_ACTED_SESSION and BORG_ACTED_LESSONS");
4754        };
4755        let state = std::fs::read_to_string(state_file_in(Path::new(&dir), &s)).unwrap();
4756        let edits = std::fs::read_to_string(state_file_in(Path::new(&dir), &s).with_extension("edits")).unwrap();
4757        let all = conscience::load_store_for(Path::new(&lessons), None, None);
4758        for fin in ["", "I replaced the drain of the budget place with a reset arc.", "Done: the hook folds the outcome history incrementally."] {
4759            let t = std::time::Instant::now();
4760            let want = acted_scan(&state, &edits, &all, fin);
4761            let scan = t.elapsed();
4762            let t = std::time::Instant::now();
4763            assert_eq!(acted_since(&state, &edits, &all, fin), want);
4764            eprintln!("scan {scan:?}, index {:?}: {} acted", t.elapsed(), want.len());
4765        }
4766        for cut in (0..edits.len()).step_by(edits.len() / 40 + 1).filter(|c| edits.is_char_boundary(*c)) {
4767            assert_eq!(acted_since(&state, &edits[..cut], &all, ""), acted_scan(&state, &edits[..cut], &all, ""), "cut {cut}");
4768        }
4769    }
4770}