Skip to main content

borg/
conscience.rs

1//! The conscience: challenges learned from engineers' corrections, asked at
2//! decision points when what the drone is working on matches.
3//!
4//! A challenge is a question, never an answer. It is recalled only when it
5//! matches the drone's prompt, touches and plan (BM25 over the question text),
6//! so a drone working on something unrelated pays zero tokens.
7
8use std::collections::HashSet;
9use std::path::Path;
10
11use serde::Deserialize;
12
13use crate::collective::{self, Aliases};
14use crate::trust;
15
16#[derive(Debug, Clone, Deserialize)]
17pub struct Challenge {
18    pub id: String,
19    /// Epoch ms of the correction this challenge was learned from.
20    pub origin_ms: i64,
21    pub question: String,
22    /// World-model form of the lesson (see predict.rs); empty for question-only lessons.
23    #[serde(default)]
24    pub situations: Vec<String>,
25    #[serde(default)]
26    pub consequence: String,
27    /// Kind of concern (trust.rs `AREAS`); "other" for lessons learned before areas existed.
28    #[serde(default)]
29    pub area: String,
30    /// Engineers whose corrections the lesson was learned from (git user.email).
31    #[serde(default)]
32    pub engineers: Vec<String>,
33    /// When each later engineer joined the lesson (learn.rs `Lesson::joined`).
34    #[serde(default)]
35    pub joined: std::collections::BTreeMap<String, i64>,
36    /// Lessons this one replaces or contradicts (set by `borg learn` or a queen answer).
37    #[serde(default)]
38    pub supersedes: Vec<String>,
39    /// "queen" for a queen answer (queen.rs `lesson`), empty otherwise.
40    #[serde(default)]
41    pub source: String,
42    /// Names the lesson is limited to (learn.rs `Lesson::scope`); empty: unscoped.
43    #[serde(default)]
44    pub scope: Vec<String>,
45    /// The outside rule this lesson replaces (learn.rs `Lesson::overrides`), shown with the question.
46    #[serde(default)]
47    pub overrides: String,
48    /// Alternative phrasings for search (learn.rs `Lesson::facets`): each is its own BM25 document of
49    /// this lesson (`rank`), never shown.
50    #[serde(default)]
51    pub facets: Vec<String>,
52    /// The repo the lesson was learned in (for an import: where the knowledge was written down).
53    #[serde(default)]
54    pub repo: String,
55    /// Who should hear it (reach.rs): empty for the team, `repo`, or `personal` (a withdrawn tombstone).
56    #[serde(default)]
57    pub reach: String,
58    /// The episodes the lesson was learned from (learn.rs `Lesson::episodes`), first first.
59    #[serde(default)]
60    pub episodes: Vec<String>,
61    /// The stored line's version (learn.rs `Lesson::version`; a merge writes the next): which wording a
62    /// check showed (pools.rs `ver`). 0 for a line without one (a ratified constraint).
63    #[serde(default)]
64    pub version: u32,
65    /// The engineer's correction of the first of `episodes` this machine holds (`with_corrections`):
66    /// shown with an `A2` line (whole.rs `line`). Empty when none resolves (a synced peer's lesson: its
67    /// episodes stay on the peer) or none was read.
68    #[serde(skip)]
69    pub correction: String,
70    /// Scoped to systems none of which is the work's known system (systems.rs `gate`): scores 0.
71    #[serde(skip)]
72    pub off_system: bool,
73    /// Memory weight (memory.rs: activation × usefulness belief), multiplies the BM25 score; 1 for a
74    /// new lesson without outcomes.
75    #[serde(skip, default = "one")]
76    pub weight: f64,
77    /// Dormant (memory.rs): recalled only on a strong match (`rank`).
78    #[serde(skip)]
79    pub dormant: bool,
80    /// Consolidated (memory.rs): may be delivered early without a scope (`early`).
81    #[serde(skip)]
82    pub consolidated: bool,
83    /// This session's Thompson draw of the lesson's usefulness relative to its mean (memory.rs): orders
84    /// the lessons that cleared the bar when there are more than the slots (`budget`, `early`); 1
85    /// without a session.
86    #[serde(skip, default = "one")]
87    pub explore: f64,
88    /// The usefulness belief behind `explore` (memory.rs `Memory::alpha`, `beta`): Beta(α, β), (1, 1)
89    /// without outcomes. For the propensity of a slot (`propensity`).
90    #[serde(skip, default = "uniform")]
91    pub belief: (f64, f64),
92    /// Mean trust of the lesson's authors in its area (trust.rs), 0.5 without evidence.
93    #[serde(skip, default = "neutral")]
94    pub trust: f64,
95    /// Its activation (memory.rs `Memory::activation`, the A behind `weight`), 0 for a new lesson:
96    /// logged beside the pool's scores (pools.rs `act`), never ranked by alone.
97    #[serde(skip)]
98    pub activation: f64,
99}
100
101fn one() -> f64 {
102    1.0
103}
104
105fn uniform() -> (f64, f64) {
106    (1.0, 1.0)
107}
108
109fn neutral() -> f64 {
110    trust::NEUTRAL
111}
112
113impl Default for Challenge {
114    fn default() -> Self {
115        Challenge {
116            id: String::new(),
117            origin_ms: 0,
118            question: String::new(),
119            situations: Vec::new(),
120            consequence: String::new(),
121            area: trust::OTHER.into(),
122            engineers: Vec::new(),
123            joined: Default::default(),
124            supersedes: Vec::new(),
125            source: String::new(),
126            scope: Vec::new(),
127            overrides: String::new(),
128            facets: Vec::new(),
129            repo: String::new(),
130            reach: String::new(),
131            episodes: Vec::new(),
132            version: 0,
133            correction: String::new(),
134            off_system: false,
135            weight: 1.0,
136            dormant: false,
137            consolidated: false,
138            explore: 1.0,
139            belief: uniform(),
140            trust: trust::NEUTRAL,
141            activation: 0.0,
142        }
143    }
144}
145
146/// Lessons known before `before_ms`. The store is append-only: when a lesson was consolidated,
147/// a later line carries the same id, and the latest version wins. Each carries its weight from
148/// `outcomes.jsonl` beside the lessons file and the team's synced outcomes (outcome.rs `read_team`, as
149/// facts.rs folds and keeps them beside the store; absent, e.g. for eval challenge files: all 1) and its authors' trust in its area, computed from
150/// this store alone (trust.rs). Engineers are
151/// canonical under this machine's aliases (collective.rs "Engineers").
152pub fn load(path: &Path, before_ms: Option<i64>) -> Vec<Challenge> {
153    load_with(path, before_ms, collective::aliases())
154}
155
156/// `load` under explicit aliases.
157pub fn load_with(path: &Path, before_ms: Option<i64>, aliases: &Aliases) -> Vec<Challenge> {
158    load_as(path, before_ms, aliases, None, false)
159}
160
161/// `load` as seen from `session`: each weight takes that session's Thompson sample of the lesson's
162/// usefulness (memory.rs), stable within the session. Without a session, the belief's mean.
163pub fn load_for(path: &Path, before_ms: Option<i64>, session: Option<&str>) -> Vec<Challenge> {
164    load_as(path, before_ms, collective::aliases(), session, false)
165}
166
167/// `load_for` with this engineer's personal lessons of the same store (reach.rs `PERSONAL_FILE`, beside
168/// `path`) over the team's: what a session of this collective is delivered. A personal lesson replaces
169/// its tombstone and is delivered like a team lesson, for this file is this machine's alone and this
170/// store this collective's alone. Costs one open more, a read only when the file exists.
171pub fn load_store_for(path: &Path, before_ms: Option<i64>, session: Option<&str>) -> Vec<Challenge> {
172    load_as(path, before_ms, collective::aliases(), session, true)
173}
174
175/// `load_store_for` under explicit aliases, without a session.
176pub fn load_store_with(path: &Path, before_ms: Option<i64>, aliases: &Aliases) -> Vec<Challenge> {
177    load_as(path, before_ms, aliases, None, true)
178}
179
180fn load_as(path: &Path, before_ms: Option<i64>, aliases: &Aliases, session: Option<&str>, personal: bool) -> Vec<Challenge> {
181    let mut out = load_unweighted_with(path, before_ms, aliases);
182    if personal {
183        with_personal(&mut out, path, before_ms, aliases);
184    }
185    // The outcomes as folded beside the store (facts.rs): what `read_team` would read, at the cost of
186    // what was appended since the last load instead of the whole history.
187    let facts = crate::facts::load(path);
188    let view = facts.view(crate::outcome::on());
189    let trust = trust::Trust::compute_with(&out, &view.trust);
190    let memory = crate::memory::derive_folded(view.folded, &out, session);
191    for c in &mut out {
192        if let Some(m) = memory.get(&c.id) {
193            // The bar (`RELATIVE`) is set on the expected weight; the session's draw only decides which
194            // of the lessons over the bar get the limited slots, so a new lesson's uncertainty cannot drop
195            // a strong match below the bar.
196            c.weight = m.weight(&c.id, None);
197            c.explore = if session.is_some() { m.weight(&c.id, session) / c.weight } else { 1.0 };
198            c.belief = (m.alpha(), m.beta());
199            c.dormant = m.dormant;
200            c.consolidated = m.consolidated;
201            c.activation = m.activation;
202        }
203        c.trust = trust.lesson(c);
204    }
205    out.extend(constraints(path, before_ms, aliases, &out));
206    out
207}
208
209/// Sets each lesson's `correction` from the `episodes.jsonl` beside `lessons` (a store's lessons file):
210/// the correction of the first of its `episodes` found there, not empty. Read only when the compact
211/// prompt shows corrections (whole.rs `line_format` `A2`), line by line. Local: episodes are never
212/// synced, so a peer's lesson keeps none.
213pub fn with_corrections(lessons: &Path, cs: &mut [Challenge]) {
214    use std::io::BufRead;
215    if crate::whole::line_format() != crate::whole::Line::A2 || cs.iter().all(|c| c.episodes.is_empty()) {
216        return;
217    }
218    let Ok(f) = std::fs::File::open(lessons.with_file_name("episodes.jsonl")) else { return };
219    let wanted: HashSet<&str> = cs.iter().flat_map(|c| c.episodes.iter().map(String::as_str)).collect();
220    #[derive(Deserialize)]
221    struct Said {
222        id: String,
223        #[serde(default)]
224        correction: String,
225    }
226    let mut said: std::collections::HashMap<String, String> = std::collections::HashMap::new();
227    for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
228        if let Ok(e) = serde_json::from_str::<Said>(&line)
229            && wanted.contains(e.id.as_str())
230            && !e.correction.trim().is_empty()
231        {
232            said.entry(e.id).or_insert(e.correction);
233        }
234    }
235    for c in cs {
236        if let Some(text) = c.episodes.iter().find_map(|e| said.get(e)) {
237            c.correction.clone_from(text);
238        }
239    }
240}
241
242/// `source` of a ratified constraint loaded as a lesson (`constraints`).
243pub const CONSTRAINT: &str = "constraint";
244
245impl Challenge {
246    /// A standing lesson: a constraint an engineer ratified (veto.rs `ratify`). Consolidated, never
247    /// dormant, its weight and trust fixed (no outcome or author moves them), and delivered early
248    /// whenever its scope is in play, whatever the match, on every harness that takes prompt context
249    /// (`standing_pool`, hook.rs `early`).
250    pub fn standing(&self) -> bool {
251        self.source == CONSTRAINT
252    }
253}
254
255/// The ratified constraints beside the lessons file `path` (veto.rs `constraints_path`) known before
256/// `before_ms`, as standing lessons: the invariant is the question, the scope and repo are those of the
257/// lesson it was proposed from (in `lessons`), its reach the narrower of the line's and the lesson's
258/// (reach.rs `narrower`): a lesson withdrawn or made a repo convention takes its constraint along. With
259/// the lesson unknown here (quarantined, not synced yet, after `before_ms`), the line's own scope and
260/// repo, bound to that repo; a line that names no repo either is left out, never delivered everywhere.
261/// One per id, the earliest ratification (by time, then author), so nodes that both ratified it read
262/// the same line after a sync, in whatever order it came.
263fn constraints(path: &Path, before_ms: Option<i64>, aliases: &Aliases, lessons: &[Challenge]) -> Vec<Challenge> {
264    let Some(dir) = path.parent() else { return Vec::new() };
265    let Ok(text) = std::fs::read_to_string(crate::veto::constraints_path(dir)) else { return Vec::new() };
266    let mut by: std::collections::BTreeMap<String, (i64, String, Challenge)> = std::collections::BTreeMap::new();
267    for v in text.lines().filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok()) {
268        let s = |k: &str| v[k].as_str().unwrap_or_default().trim().to_string();
269        let (id, question, ms) = (s("id"), s("text"), v["ms"].as_i64().unwrap_or(0));
270        if id.is_empty() || question.is_empty() || before_ms.is_some_and(|b| ms >= b) {
271            continue;
272        }
273        let author = aliases.canon(&s("by"));
274        if by.get(&id).is_some_and(|(m, a, _)| (*m, a.as_str()) <= (ms, author.as_str())) {
275            continue;
276        }
277        let line_scope: Vec<String> = v["scope"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).map(str::to_string).collect();
278        let (scope, repo, reach, area) = match lessons.iter().find(|l| l.id == s("lesson")) {
279            Some(l) => {
280                let repo = if l.repo.is_empty() { s("repo") } else { l.repo.clone() };
281                (l.scope.clone(), repo, crate::reach::narrower(&s("reach"), &l.reach).to_string(), l.area.clone())
282            }
283            None if s("repo").is_empty() => continue,
284            None => (line_scope, s("repo"), crate::reach::narrower(&s("reach"), crate::reach::REPO).to_string(), trust::OTHER.into()),
285        };
286        let c = Challenge {
287            id: id.clone(),
288            origin_ms: ms,
289            question,
290            source: CONSTRAINT.into(),
291            engineers: Some(author.clone()).filter(|a| !a.is_empty()).into_iter().collect(),
292            scope,
293            repo,
294            reach,
295            area,
296            consolidated: true,
297            ..Challenge::default()
298        };
299        by.insert(id, (ms, author, c));
300    }
301    by.into_values().map(|(_, _, c)| c).collect()
302}
303
304/// This engineer's personal lessons of the store at `path` (reach.rs `PERSONAL_FILE`) over the team's in
305/// `out`, as `load_store_for` delivers them: each replaces the team's line of its id (a tombstone too).
306fn with_personal(out: &mut Vec<Challenge>, path: &Path, before_ms: Option<i64>, aliases: &Aliases) {
307    let mut mine = load_unweighted_with(&path.with_file_name(crate::reach::PERSONAL_FILE), before_ms, aliases);
308    // A lesson `borg move` took elsewhere leaves its tombstone here: nothing to deliver.
309    mine.retain(|c| c.question != crate::reach::MOVED);
310    if !mine.is_empty() {
311        out.retain(|c| !mine.iter().any(|m| m.id == c.id));
312        out.extend(mine.into_iter().map(|c| Challenge { reach: String::new(), ..c }));
313    }
314}
315
316/// `load_unweighted` with this engineer's personal lessons over the team's (`load_store_for`'s texts,
317/// without weight or trust): the corpus the pool log scores a session's lessons in (pools.rs).
318pub fn load_store_unweighted(path: &Path, before_ms: Option<i64>) -> Vec<Challenge> {
319    let aliases = collective::aliases();
320    let mut out = load_unweighted_with(path, before_ms, aliases);
321    with_personal(&mut out, path, before_ms, aliases);
322    out
323}
324
325/// The lessons as stored (latest version per id, area normalised, engineers canonical), without
326/// weight or trust.
327pub fn load_unweighted(path: &Path, before_ms: Option<i64>) -> Vec<Challenge> {
328    load_unweighted_with(path, before_ms, collective::aliases())
329}
330
331/// `load_unweighted` under explicit aliases.
332pub fn load_unweighted_with(path: &Path, before_ms: Option<i64>, aliases: &Aliases) -> Vec<Challenge> {
333    let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
334    // Index by id: a linear scan per line made loading O(n²) (29 s at 100k lessons).
335    let mut out: Vec<Challenge> = Vec::new();
336    let mut at: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
337    for mut c in text.lines().filter_map(|l| serde_json::from_str::<Challenge>(l).ok()) {
338        c.area = trust::area(&c.area).into();
339        canonical(&mut c, aliases);
340        if before_ms.is_some_and(|b| c.origin_ms >= b) {
341            continue;
342        }
343        match at.get(&c.id) {
344            Some(&i) => out[i] = c,
345            None => {
346                at.insert(c.id.clone(), out.len());
347                out.push(c);
348            }
349        }
350    }
351    out
352}
353
354/// Map a lesson's engineers to who they are (`Aliases::canon`), at read time: the store keeps the
355/// raw emails. Several emails of one engineer become one author; that author joined when the
356/// earliest of them did, and not at all (counts from the start) if any of them is the first
357/// contributor.
358fn canonical(c: &mut Challenge, aliases: &Aliases) {
359    let mut engineers: Vec<String> = Vec::new();
360    for e in c.engineers.iter().map(|e| aliases.canon(e)).filter(|e| !e.is_empty()) {
361        if !engineers.contains(&e) {
362            engineers.push(e);
363        }
364    }
365    let joined = engineers
366        .iter()
367        .filter_map(|who| {
368            let raws = c.engineers.iter().filter(|r| aliases.canon(r) == *who);
369            let at: Option<Vec<i64>> = raws.map(|r| c.joined.get(r).copied()).collect();
370            Some((who.clone(), at?.into_iter().min()?))
371        })
372        .collect();
373    (c.engineers, c.joined) = (engineers, joined);
374}
375
376/// Lowercased words of 3+ characters, plus the parts of camelCase words.
377pub fn tokens(text: &str) -> Vec<String> {
378    let mut out = Vec::new();
379    tokens_at(text, |_, t| out.push(t.to_string()));
380    out
381}
382
383/// `tokens`, each handed to `f` with the byte offset in `text` of the word it comes from (a camelCase
384/// part has its word's): the same tokens in the same order, lowercased into one reused buffer, so an
385/// ASCII word costs no allocation (hook.rs `early_acted` indexes a whole session this way).
386pub fn tokens_at(text: &str, mut f: impl FnMut(usize, &str)) {
387    let mut low = String::new();
388    let mut emit = |at: usize, s: &str| {
389        low.clear();
390        if s.is_ascii() {
391            low.push_str(s);
392            low.make_ascii_lowercase();
393        } else {
394            low.push_str(&s.to_lowercase());
395        }
396        f(at, &low);
397    };
398    for raw in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
399        if raw.len() < 3 {
400            continue;
401        }
402        let at = raw.as_ptr() as usize - text.as_ptr() as usize;
403        emit(at, raw);
404        let mut start = 0;
405        for (i, ch) in raw.char_indices() {
406            if ch.is_uppercase() && i > start {
407                let word = &raw[start..i];
408                if word.len() >= 3 && word.len() < raw.len() {
409                    emit(at, word);
410                }
411                start = i;
412            }
413        }
414        let word = &raw[start..];
415        if word.len() >= 3 && word.len() < raw.len() {
416            emit(at, word);
417        }
418    }
419}
420
421#[cfg(test)]
422fn tokens_0_7_7(text: &str) -> Vec<String> {
423    let mut out = Vec::new();
424    for raw in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
425        if raw.len() < 3 {
426            continue;
427        }
428        out.push(raw.to_lowercase());
429        let mut word = String::new();
430        for ch in raw.chars() {
431            if ch.is_uppercase() && !word.is_empty() {
432                if word.len() >= 3 && word.len() < raw.len() {
433                    out.push(word.to_lowercase());
434                }
435                word.clear();
436            }
437            word.push(ch);
438        }
439        if word.len() >= 3 && word.len() < raw.len() {
440            out.push(word.to_lowercase());
441        }
442    }
443    out
444}
445
446/// BM25 over the challenges' own question text, queried with the drone's
447/// context (prompt, touches, its plan). Evaluated against keyword-IDF, dense
448/// and reranked retrieval on graded qrels (internal evaluation "retrieval-plan-queries", not published):
449/// BM25 ranked the relevant lessons highest. Each score is multiplied by the
450/// challenge's outcome weight, so a lesson that keeps being asked and then
451/// corrected anyway sinks below equally relevant ones, and by its authors'
452/// trust in its area relative to neutral (trust / 0.5: no evidence leaves the
453/// score unchanged). A lesson hidden by its successor is never ranked; the live
454/// successor inherits its relevance (trust.rs `successors`), so the replacing
455/// claim is asked where the replaced one would have been. A supersession that
456/// does not hide (a less trusted engineer contradicting another) keeps both
457/// live, and `render` shows them as a disagreement. A scoped lesson whose scope
458/// the context never names scores 0 (`in_scope`): it is neither asked nor passes
459/// its relevance on. Returns every challenge with score > 0, best first.
460///
461/// A lesson with facets (facets.rs) is several documents: its question and each facet. Its score is its
462/// best document's (`bm25_groups`), with idf counted over lessons, so a store without facets ranks
463/// exactly as BM25 over the questions, and the prefilter's normalisation by the largest idf stays valid.
464pub fn rank<'a>(challenges: &'a [Challenge], context: &str) -> Vec<(f64, &'a Challenge)> {
465    rank_scoped(challenges, context, context)
466}
467
468/// The drone's work in `context` (a session's state file plus its work text) as word matching, the
469/// vectors and the consequence model read it: without borg's own bookkeeping lines (`#borg-asked`,
470/// `#borg-p`, `#borg-pool`, every `#borg-` marker). They carry lesson ids, scores and costs, not work.
471pub fn work_text(context: &str) -> std::borrow::Cow<'_, str> {
472    if !context.contains("#borg-") {
473        return std::borrow::Cow::Borrowed(context);
474    }
475    std::borrow::Cow::Owned(context.split_inclusive('\n').filter(|l| !l.starts_with("#borg-")).collect())
476}
477
478/// `rank`, with scopes judged against `scope_text` (where the session works) instead of the whole
479/// context: a system the conversation only talks about is not one the drone works on.
480pub fn rank_scoped<'a>(challenges: &'a [Challenge], context: &str, scope_text: &str) -> Vec<(f64, &'a Challenge)> {
481    rank_raw(challenges, raw(challenges, context), scope_text)
482}
483
484/// Each challenge's word match against `context`, before scope, dormancy, successors, weight and trust
485/// (`rank_scoped`): BM25 of its documents (`documents`, `bm25_groups`), in challenge order. A check
486/// logs it (pools.rs `bm25q`).
487pub fn raw(challenges: &[Challenge], context: &str) -> Vec<f64> {
488    let groups: Vec<Vec<Vec<String>>> = challenges.iter().map(documents).collect();
489    bm25_groups(&groups, context, 0.0)
490}
491
492/// `rank_scoped` from the word matches `raw` computed (aligned with `challenges`).
493pub fn rank_raw<'a>(challenges: &'a [Challenge], mut raw: Vec<f64>, scope_text: &str) -> Vec<(f64, &'a Challenge)> {
494    if challenges.iter().any(|c| !c.scope.is_empty()) {
495        let have = Names::of(scope_text);
496        for (r, c) in raw.iter_mut().zip(challenges) {
497            if !in_scope(c, &have) || c.off_system {
498                *r = 0.0;
499            }
500        }
501    }
502    // A dormant lesson (memory.rs) is recalled only on a strong match: `STRONG` × the best match.
503    let best = raw.iter().copied().fold(0.0, f64::max);
504    for (r, c) in raw.iter_mut().zip(challenges) {
505        if c.dormant && *r < STRONG * best {
506            *r = 0.0;
507        }
508    }
509    let successors = trust::successors(challenges);
510    let mut relevance = raw.clone();
511    if !successors.is_empty() {
512        let at: std::collections::HashMap<&str, usize> = challenges.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
513        for (i, c) in challenges.iter().enumerate() {
514            if let Some(&j) = successors.get(&c.id).and_then(|s| at.get(s.as_str())) {
515                relevance[j] = relevance[j].max(raw[i]);
516            }
517        }
518    }
519    let mut scored: Vec<(f64, &Challenge)> = relevance
520        .into_iter()
521        .zip(challenges)
522        .filter(|(_, c)| !successors.contains_key(&c.id))
523        .map(|(s, c)| (s * c.weight * c.trust / trust::NEUTRAL, c))
524        .filter(|(s, _)| *s > 0.0)
525        .collect();
526    scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(b.1.origin_ms.cmp(&a.1.origin_ms)));
527    scored
528}
529
530/// A dormant lesson needs at least this share of the best match to be recalled (`rank`). Baseline:
531/// design-chosen with dormancy (memory.rs `DORMANT_BELOW`), part of today's ranking; the same value as
532/// `BLOCK`, a different bar.
533pub const STRONG: f64 = 0.8;
534
535/// Whether a lesson applies to a context: unscoped, or one of its scope names occurs in it as a
536/// contiguous phrase or identifier (`Names::contains`). "Answer Guard" needs "answer guard" (or
537/// `AnswerGuard`), never "output" in one sentence and "guard" in another; "Orion" matches a path
538/// `orion-text/` and `OrionConfig`; no part of a name is ever dropped for being short.
539pub fn in_scope(c: &Challenge, have: &Names) -> bool {
540    c.scope.is_empty() || c.scope.iter().any(|s| have.contains(s))
541}
542
543/// Lowercased name parts in order: split at every non-alphanumeric character and at camelCase
544/// humps (`HTTPClientFactory` -> http, client, factory), digits kept with their letters (`v2`, `sr3`).
545pub fn segments(text: &str) -> Vec<String> {
546    let mut out = Vec::new();
547    for raw in text.split(|c: char| !c.is_alphanumeric()).filter(|r| !r.is_empty()) {
548        let cs: Vec<char> = raw.chars().collect();
549        let mut word = String::new();
550        for (i, &ch) in cs.iter().enumerate() {
551            let hump = i > 0
552                && ch.is_uppercase()
553                && (cs[i - 1].is_lowercase() || cs[i - 1].is_numeric() || (cs[i - 1].is_uppercase() && cs.get(i + 1).is_some_and(|n| n.is_lowercase())));
554            if hump && !word.is_empty() {
555                out.push(std::mem::take(&mut word).to_lowercase());
556            }
557            word.push(ch);
558        }
559        out.push(word.to_lowercase());
560    }
561    out
562}
563
564/// A text as its name parts, indexed for contiguous lookups.
565pub struct Names {
566    seq: Vec<String>,
567    at: std::collections::HashMap<String, Vec<usize>>,
568}
569
570impl Names {
571    pub fn of(text: &str) -> Self {
572        Self::of_all([text])
573    }
574
575    /// Several texts, none adjacent to the next: a name never matches across two of them.
576    pub fn of_all<'a>(texts: impl IntoIterator<Item = &'a str>) -> Self {
577        let mut seq = Vec::new();
578        for t in texts {
579            seq.extend(segments(t));
580            seq.push(String::new()); // a part no name has
581        }
582        let mut at: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
583        for (i, s) in seq.iter().enumerate() {
584            at.entry(s.clone()).or_default().push(i);
585        }
586        Names { seq, at }
587    }
588
589    /// Whether every part of `name` occurs here, in order and adjacent. A name without parts never matches.
590    pub fn contains(&self, name: &str) -> bool {
591        let need = segments(name);
592        let Some(first) = need.first() else { return false };
593        self.at.get(first).is_some_and(|starts| starts.iter().any(|&i| self.seq.get(i..i + need.len()).is_some_and(|w| w == need.as_slice())))
594    }
595}
596
597/// Okapi BM25's term-frequency saturation and length normalisation (`bm25`, `bm25_groups`). Method:
598/// the standard parameters (Robertson and Zaragoza, "The Probabilistic Relevance Framework: BM25 and
599/// Beyond", 2009); never tuned on borg's data.
600pub const BM25_K1: f64 = 1.2;
601pub const BM25_B: f64 = 0.75;
602
603/// Okapi BM25 (`BM25_K1`, `BM25_B`) of each tokenised document against the query text, in document order.
604pub fn bm25(docs: &[Vec<String>], query: &str) -> Vec<f64> {
605    let (k1, b) = (BM25_K1, BM25_B);
606    let n = docs.len().max(1) as f64;
607    let avg = docs.iter().map(Vec::len).sum::<usize>() as f64 / n;
608    let mut df: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
609    for d in docs {
610        for t in d.iter().map(String::as_str).collect::<HashSet<_>>() {
611            *df.entry(t).or_default() += 1.0;
612        }
613    }
614    let query: HashSet<String> = tokens(query).into_iter().collect();
615    docs.iter()
616        .map(|d| {
617            let mut tf: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
618            for t in d {
619                *tf.entry(t.as_str()).or_default() += 1.0;
620            }
621            query
622                .iter()
623                .filter_map(|t| tf.get(t.as_str()).map(|f| (t, *f)))
624                .map(|(t, f)| {
625                    let dft = df[t.as_str()];
626                    let idf = (1.0 + (n - dft + 0.5) / (dft + 0.5)).ln();
627                    idf * f * (k1 + 1.0) / (f + k1 * (1.0 - b + b * d.len() as f64 / avg))
628                })
629                .sum()
630        })
631        .collect()
632}
633
634/// A lesson's search documents: its question, then each facet (`Challenge::facets`).
635pub fn documents(c: &Challenge) -> Vec<Vec<String>> {
636    std::iter::once(tokens(&c.question)).chain(c.facets.iter().map(|f| tokens(f)).filter(|d| !d.is_empty())).collect()
637}
638
639/// BM25 (`BM25_K1`, `BM25_B`) of groups of documents, one group per lesson, against the query text: a
640/// group scores its best document plus `second` × its second best (0: the best alone). Document
641/// frequencies count groups (a word in three facets of one lesson is one lesson), so idf means what it
642/// means in `bm25`; term frequency and length are per document. Groups of one document score as `bm25`
643/// does. Only query words are counted, which keeps a 1k-lesson store with facets fast.
644pub fn bm25_groups(groups: &[Vec<Vec<String>>], query: &str, second: f64) -> Vec<f64> {
645    let (k1, b) = (BM25_K1, BM25_B);
646    let n = groups.len().max(1) as f64;
647    let ndocs = groups.iter().map(Vec::len).sum::<usize>().max(1) as f64;
648    let avg = groups.iter().flatten().map(Vec::len).sum::<usize>() as f64 / ndocs;
649    let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
650    for t in tokens(query) {
651        let next = index.len();
652        index.entry(t).or_insert(next);
653    }
654    // Per document: (query word, tf), ordered by query word, so equal documents sum in the same order.
655    let counted: Vec<Vec<Vec<(usize, f64)>>> = groups
656        .iter()
657        .map(|g| {
658            g.iter()
659                .map(|d| {
660                    let mut tf: Vec<(usize, f64)> = Vec::new();
661                    for q in d.iter().filter_map(|t| index.get(t.as_str())) {
662                        match tf.iter_mut().find(|(x, _)| x == q) {
663                            Some((_, f)) => *f += 1.0,
664                            None => tf.push((*q, 1.0)),
665                        }
666                    }
667                    tf.sort_by_key(|(q, _)| *q);
668                    tf
669                })
670                .collect()
671        })
672        .collect();
673    let mut df = vec![0.0f64; index.len()];
674    let mut seen: Vec<usize> = Vec::new();
675    for g in &counted {
676        seen.clear();
677        for &(q, _) in g.iter().flatten() {
678            if !seen.contains(&q) {
679                seen.push(q);
680                df[q] += 1.0;
681            }
682        }
683    }
684    let idf: Vec<f64> = df.iter().map(|&d| (1.0 + (n - d + 0.5) / (d + 0.5)).ln()).collect();
685    counted
686        .iter()
687        .zip(groups)
688        .map(|(g, docs)| {
689            let (mut best, mut next) = (0.0f64, 0.0f64);
690            for (tf, d) in g.iter().zip(docs) {
691                let norm = k1 * (1.0 - b + b * d.len() as f64 / avg);
692                let s: f64 = tf.iter().map(|&(q, f)| idf[q] * f * (k1 + 1.0) / (f + norm)).sum();
693                if s > best {
694                    (best, next) = (s, best);
695                } else if s > next {
696                    next = s;
697                }
698            }
699            best + second * next
700        })
701        .collect()
702}
703
704/// The top `k` ranked challenges.
705pub fn select<'a>(challenges: &'a [Challenge], context: &str, k: usize) -> Vec<&'a Challenge> {
706    select_scoped(challenges, context, context, k)
707}
708
709/// `select` with scopes judged against `scope_text` (`rank_scoped`).
710pub fn select_scoped<'a>(challenges: &'a [Challenge], context: &str, scope_text: &str, k: usize) -> Vec<&'a Challenge> {
711    rank_scoped(challenges, context, scope_text).into_iter().take(k).map(|(_, c)| c).collect()
712}
713
714/// An early delivery takes lessons scoring at least this share of the best on-topic match (`early`).
715/// Baseline: today's early rule (R3b, internal evaluation "phase-r"), kept until a learned early rule
716/// is fitted on the share each early delivery's record carries per lesson (outcome.rs `rel`, hook.rs
717/// `early_rel`; empty when no plain lesson was on topic). Those records hold only delivered lessons, and
718/// every plain one scored at least this bar, so a rule fitted on them alone can learn to raise it, not
719/// what a lower bar would admit.
720pub const RELATIVE: f64 = 0.5;
721/// The Stop blocks only when a lesson not yet done scores at least this share of the best match
722/// (`budget`): a block is an extra round, the expensive part. Baseline: the R3b word-matching rule
723/// (`BORG_MATCH=bm25`), not the default path.
724pub const BLOCK: f64 = 0.8;
725/// Once the Stop blocks, every lesson not done scoring at least this share of the best match is asked
726/// (`budget`): more questions in a round that happens anyway cost little. Baseline: as `BLOCK`.
727pub const LOW: f64 = 0.3;
728/// At most this many lessons per Stop block. Baseline: the fixed graded rule's block and free hand-over
729/// (`graded_capped`), the reference the learned policy is gated against (select.rs); it also bounds a
730/// block's length.
731pub const CAP: usize = 10;
732/// At most this many unscoped (generic) lessons per block: generic hygiene does not fill the block.
733/// Baseline: part of the R3b rule (`in_scope_first`).
734pub const GENERIC_CAP: usize = 3;
735
736/// The Stop's two decisions over a ranking of the whole store (best first, as `rank` returns it; the
737/// top is the store's best match, done or not), with `done` the lessons this session already asked,
738/// or delivered early and acted on (R3b, internal evaluation "phase-r", not published):
739/// - **whether to block** (the cost: one more round): only when a lesson not done scores at least
740///   `BLOCK` × the top. A context whose best lessons were delivered early and acted on does not block.
741/// - **what to ask once blocking** (cheap): every lesson not done scoring at least `LOW` × the top,
742///   scoped lessons first (`rank` already dropped those out of scope), at most `GENERIC_CAP` unscoped,
743///   `CAP` in all, ordered by score × the session's usefulness draw (R1, `explored`).
744pub fn budget<'a>(ranked: &[(f64, &'a Challenge)], done: &HashSet<&str>) -> Vec<&'a Challenge> {
745    in_scope_first(explored(budget_pool(ranked, done)))
746}
747
748/// `budget`'s pool before the slots: every lesson not done over `LOW` × the top, with its score; empty
749/// when none not done clears `BLOCK` × the top.
750pub fn budget_pool<'a>(ranked: &[(f64, &'a Challenge)], done: &HashSet<&str>) -> Vec<(f64, &'a Challenge)> {
751    let Some(&(top, _)) = ranked.first() else { return Vec::new() };
752    if !ranked.iter().any(|(s, c)| *s >= BLOCK * top && !done.contains(c.id.as_str())) {
753        return Vec::new();
754    }
755    ranked.iter().copied().filter(|(s, c)| *s >= LOW * top && !done.contains(c.id.as_str())).collect()
756}
757
758/// The consequence model decided to block (`predicted`, non-empty): ask its lessons and every other
759/// lesson not done over `LOW` × the best match, in-scope first, ordered by score × draw, at most `CAP`.
760pub fn widen<'a>(ranked: &[(f64, &'a Challenge)], predicted: &[&'a Challenge], done: &HashSet<&str>) -> Vec<&'a Challenge> {
761    in_scope_first(explored(widen_pool(ranked, predicted, done)))
762}
763
764/// `widen`'s pool before the slots: the model's picks (at least the top score) and every other lesson
765/// not done over `LOW` × the top, with their scores; empty when the model picked none.
766pub fn widen_pool<'a>(ranked: &[(f64, &'a Challenge)], predicted: &[&'a Challenge], done: &HashSet<&str>) -> Vec<(f64, &'a Challenge)> {
767    if predicted.is_empty() {
768        return Vec::new();
769    }
770    let top = ranked.first().map_or(0.0, |r| r.0);
771    let score = |c: &Challenge| ranked.iter().find(|(_, x)| x.id == c.id).map_or(0.0, |r| r.0);
772    // The model's picks stay in even below the bar; they lead ties.
773    let mut pool: Vec<(f64, &Challenge)> = predicted.iter().map(|c| (score(c).max(top), *c)).collect();
774    for &(s, c) in ranked {
775        if s >= LOW * top && !done.contains(c.id.as_str()) && !pool.iter().any(|(_, x)| x.id == c.id) {
776            pool.push((s, c));
777        }
778    }
779    pool
780}
781
782/// The graded Stop decision (the default, unless `BORG_STOP_GRADED=off`; internal evaluation "whole-store", "Graded follow-up", not published) over the
783/// model's graded applies, in its order (3 certain, 2 likely, 1 worth a thought). Returns what to ask in
784/// a block and what to hand over free:
785/// - **block** (an extra round) only when at least one lesson is graded 3; then ask every 3 and 2, and
786///   the 1s while the block has fewer than `CAP` lessons. 2s and 1s alone never block.
787/// - **without a block**, the 2s and then the 1s, at most `CAP`, go to the drone free as early context at
788///   its next prompt or touch (hook.rs `queued`).
789pub fn graded<'a>(applied: &[(&'a Challenge, u8)]) -> (Vec<&'a Challenge>, Vec<&'a Challenge>) {
790    graded_capped(applied, ask_cap())
791}
792
793/// `BORG_STOP_ASK_CAP=N` (evaluation only): a block asks only the 3s, the first `N` in the model's
794/// order; without a 3 nothing changes. Unset, a block asks as `graded` says.
795pub fn ask_cap() -> Option<usize> {
796    std::env::var("BORG_STOP_ASK_CAP").ok().and_then(|v| v.trim().parse().ok()).filter(|n| *n > 0)
797}
798
799/// `graded` with an explicit cap on a block (`ask_cap`).
800pub fn graded_capped<'a>(applied: &[(&'a Challenge, u8)], cap: Option<usize>) -> (Vec<&'a Challenge>, Vec<&'a Challenge>) {
801    graded_without(applied, cap, &[])
802}
803
804/// `graded_capped` for a session that already had the lessons `had` delivered (ids in any order,
805/// select.rs `Ctx::had`): such a lesson may still be asked, but never takes a free slot, which the
806/// delivery would drop as given before (hook.rs `queued`); the next 2 or 1 takes it. With `had` empty
807/// this is `graded_capped`, the rule every record before select.rs `nofree` was decided by.
808pub fn graded_without<'a>(applied: &[(&'a Challenge, u8)], cap: Option<usize>, had: &[String]) -> (Vec<&'a Challenge>, Vec<&'a Challenge>) {
809    let of = |g: u8| applied.iter().filter(move |(_, x)| *x == g).map(|(c, _)| *c);
810    if of(3).next().is_none() {
811        return (Vec::new(), of(2).chain(of(1)).filter(|c| !had.contains(&c.id)).take(CAP).collect());
812    }
813    if let Some(n) = cap {
814        return (of(3).take(n).collect(), Vec::new());
815    }
816    let mut ask: Vec<&Challenge> = of(3).chain(of(2)).collect();
817    let room = CAP.saturating_sub(ask.len());
818    ask.extend(of(1).take(room));
819    (ask, Vec::new())
820}
821
822/// Lessons that cleared the bar, in the order of score × the session's usefulness draw (`explore`):
823/// Thompson sampling over the slots. Without a session every draw is 1 and this is the score order.
824pub fn explored<'a>(passed: impl IntoIterator<Item = (f64, &'a Challenge)>) -> Vec<&'a Challenge> {
825    let mut v: Vec<(f64, &Challenge)> = passed.into_iter().map(|(s, c)| (s * c.explore, c)).collect();
826    v.sort_by(|a, b| b.0.total_cmp(&a.0));
827    v.into_iter().map(|(_, c)| c).collect()
828}
829
830/// Candidates in their order, scoped ones first, at most `GENERIC_CAP` unscoped, `CAP` in all.
831pub fn in_scope_first<'a>(candidates: impl IntoIterator<Item = &'a Challenge>) -> Vec<&'a Challenge> {
832    let (scoped, generic): (Vec<&Challenge>, Vec<&Challenge>) = candidates.into_iter().partition(|c| !c.scope.is_empty());
833    scoped.into_iter().chain(generic.into_iter().take(GENERIC_CAP)).take(CAP).collect()
834}
835
836/// Method: the draws behind a propensity estimate (`propensity`): precision, not a tuning knob
837/// (standard error ≤ 1/(2√P_DRAWS) ≈ 0.022).
838pub const P_DRAWS: usize = 512;
839
840/// Each pooled lesson's chance of a slot under the session's posterior (`budget`, `widen`): the share of
841/// `P_DRAWS` alternative draws in which `in_scope_first`, over the pool ordered by score × draw, keeps
842/// it. A draw is the ratio `explore` is (memory.rs `Memory::weight`): (0.5 + x) / (0.5 + mean), x from
843/// the lesson's belief, seeded by (`session#p<i>`, id). Exactly 1 for every lesson when the pool fits the
844/// slots, without a draw. Deterministic.
845pub fn propensity(pool: &[(f64, &Challenge)], session: &str) -> std::collections::HashMap<String, f64> {
846    if pool.iter().filter(|(_, c)| c.scope.is_empty()).count() <= GENERIC_CAP && pool.len() <= CAP {
847        return pool.iter().map(|(_, c)| (c.id.clone(), 1.0)).collect();
848    }
849    kept_share(pool, session, |order| in_scope_first(order).into_iter().map(|c| c.id.clone()).collect())
850}
851
852/// The share of `P_DRAWS` draws (`propensity`) in which `keep`, given the pool ordered by score × draw,
853/// keeps each pooled lesson.
854fn kept_share(pool: &[(f64, &Challenge)], session: &str, keep: impl Fn(Vec<&Challenge>) -> Vec<String>) -> std::collections::HashMap<String, f64> {
855    let draw = |c: &Challenge, i: usize| {
856        let (a, b) = c.belief;
857        let x = crate::memory::draw(a, b, crate::memory::seed(&format!("{session}#p{i}"), &c.id));
858        (0.5 + x) / (0.5 + a / (a + b))
859    };
860    let mut kept: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
861    for i in 0..P_DRAWS {
862        let mut v: Vec<(f64, &Challenge)> = pool.iter().map(|&(s, c)| (s * draw(c, i), c)).collect();
863        v.sort_by(|a, b| b.0.total_cmp(&a.0));
864        for id in keep(v.into_iter().map(|(_, c)| c).collect()) {
865            *kept.entry(id).or_default() += 1;
866        }
867    }
868    pool.iter().map(|(_, c)| (c.id.clone(), kept.get(&c.id).copied().unwrap_or(0) as f64 / P_DRAWS as f64)).collect()
869}
870
871/// A pooled lesson's score without its usefulness factor: s / (0.5 + mean belief) = relevance ×
872/// squash(A) × trust / NEUTRAL (`rank`, memory.rs `Memory::weight`). Under another belief the score is
873/// base × (0.5 + its mean), and `propensity` orders by base × (0.5 + draw), so `borg debug ope` (ope.rs) can
874/// replay a slot under other weights.
875pub fn pool_base(s: f64, c: &Challenge) -> f64 {
876    let (a, b) = c.belief;
877    s / (0.5 + a / (a + b))
878}
879
880/// At most this many lessons per early delivery (`early`). Baseline: today's early rule, as `RELATIVE`.
881pub const EARLY_CAP: usize = 5;
882/// An early delivery's text stops before this many characters (about 500 tokens), a free hand-over's
883/// too (hook.rs `queued`), less the lines handed over in place of questions (`render_owing`). Guard: bounds the context one delivery adds to the drone's turn; what it
884/// cuts is logged (`dropped_chars`, outcome.rs `DELIVERY`), so its hit rate is measured.
885pub const EARLY_CHARS: usize = 2000;
886/// The characters of a lesson's question an early delivery's line shows at most, then `…`
887/// (`render_early`). Display: short enough that `EARLY_CHARS` holds several lessons, long enough for
888/// a question.
889pub const EARLY_QUESTION_CHARS: usize = 300;
890/// The characters of its consequence the line shows at most. Display: the consequence explains the
891/// question, so it gets less room.
892pub const EARLY_CONSEQUENCE_CHARS: usize = 200;
893/// The characters of the team rule it replaces the line shows at most. Display: a pointer to the old
894/// rule, not the rule itself.
895pub const EARLY_OVERRIDES_CHARS: usize = 150;
896
897/// The scope names of `challenges` that `text` names (`Names::contains`), unique, in store order.
898pub fn named_scopes(challenges: &[Challenge], text: &str) -> Vec<String> {
899    let have = Names::of(text);
900    let mut out: Vec<String> = Vec::new();
901    for s in challenges.iter().flat_map(|c| &c.scope) {
902        if !out.contains(s) && have.contains(s) {
903            out.push(s.clone());
904        }
905    }
906    out
907}
908
909/// Early delivery: the scoped lessons a context brings into scope through one of `topics` (scope names
910/// it just named for the first time), ranked over the whole `context`, scoring at least `RELATIVE` × the
911/// best of them (done or not), minus those already delivered or asked (`done`), at most `EARLY_CAP`. Unscoped lessons
912/// never come early: generic hygiene has no topic, and only a named system says the drone is on one. Standing
913/// lessons (ratified constraints, `Challenge::standing`) come first on their topic, matched or not (`standing_pool`).
914pub fn early<'a>(challenges: &'a [Challenge], context: &str, topics: &[String], done: &HashSet<&str>) -> Vec<&'a Challenge> {
915    let pool: Vec<(f64, &Challenge)> = standing_pool(challenges, topics, done).into_iter().chain(early_pool(challenges, context, topics, done)).collect();
916    explored(pool).into_iter().take(EARLY_CAP).collect()
917}
918
919/// `early`'s pool of plain lessons before the slots: every on-topic lesson not done over `RELATIVE` ×
920/// the best of them, with its score. Never a standing lesson: those need no match (`standing_pool`).
921pub fn early_pool<'a>(challenges: &'a [Challenge], context: &str, topics: &[String], done: &HashSet<&str>) -> Vec<(f64, &'a Challenge)> {
922    let on_topic = Names::of_all(topics.iter().map(String::as_str));
923    let any = topics.iter().any(|t| t == ANY);
924    let ranked: Vec<(f64, &Challenge)> = rank(challenges, context)
925        .into_iter()
926        .filter(|(_, c)| !c.standing() && ((!c.scope.is_empty() && c.scope.iter().any(|s| on_topic.contains(s))) || (any && c.consolidated)))
927        .collect();
928    // The best on-topic match, done or not, sets the bar (as in `budget`).
929    let Some(&(top, _)) = ranked.first() else { return Vec::new() };
930    ranked.into_iter().filter(|(s, c)| *s >= RELATIVE * top && !done.contains(c.id.as_str())).collect()
931}
932
933/// The standing lessons on `topics` not done (a named scope's; with `ANY`, the unscoped ones), each
934/// with an infinite score: it comes ahead of every match and of every draw, matched or not.
935pub fn standing_pool<'a>(challenges: &'a [Challenge], topics: &[String], done: &HashSet<&str>) -> Vec<(f64, &'a Challenge)> {
936    let on_topic = Names::of_all(topics.iter().map(String::as_str));
937    let any = topics.iter().any(|t| t == ANY);
938    challenges
939        .iter()
940        .filter(|c| c.standing() && !c.off_system && !done.contains(c.id.as_str()))
941        .filter(|c| c.scope.iter().any(|s| on_topic.contains(s)) || (any && c.scope.is_empty()))
942        .map(|c| (f64::INFINITY, c))
943        .collect()
944}
945
946/// Each pooled lesson's chance of being delivered early under the session's posterior (`early` over
947/// `early_pool`): `propensity`'s draws, keeping in each the first `EARLY_CAP` and of them what
948/// `render_early` shows for `topics`. Exactly 1 for every lesson when the whole pool always shows.
949pub fn early_propensity(pool: &[(f64, &Challenge)], session: &str, topics: &[String]) -> std::collections::HashMap<String, f64> {
950    let delivered = |order: Vec<&Challenge>| -> Vec<String> {
951        let first: Vec<&Challenge> = order.into_iter().take(EARLY_CAP).collect();
952        let n = render_early(&first, topics).map_or(0, |(_, n)| n);
953        first[..n].iter().map(|c| c.id.clone()).collect()
954    };
955    if delivered(pool.iter().map(|(_, c)| *c).collect()).len() == pool.len() {
956        return pool.iter().map(|(_, c)| (c.id.clone(), 1.0)).collect();
957    }
958    kept_share(pool, session, delivered)
959}
960
961/// The topic of consolidated lessons (memory.rs) in `early`: they need no scope, the session's first
962/// prompt brings them.
963pub const ANY: &str = "*";
964
965/// An early delivery as the drone reads it: labelled peer data, one line per lesson (question, then its
966/// consequence), within `EARLY_CHARS`, and how many of `selected` it shows (the first ones). `None` when
967/// not even one line fits.
968pub fn render_early(selected: &[&Challenge], topics: &[String]) -> Option<(String, usize)> {
969    render_owing(selected, topics, 0)
970}
971
972/// `render_early` whose first `owed` lessons are shown whatever the budget and count nothing toward
973/// it: a free delivery's hand-overs in place of questions the fixed rule asks (hook.rs `INSTEAD_NEXT`: the
974/// evidence rule's; and a live check's lines from before 0.7.6, `ASK_NEXT`, `LEGACY_INSTEAD`), which
975/// the asks they replace would have carried without a bound. The rest fit `EARLY_CHARS` as they would without them.
976pub fn render_owing(selected: &[&Challenge], topics: &[String], owed: usize) -> Option<(String, usize)> {
977    let clip = |s: &str, n: usize| -> String {
978        let s = s.trim();
979        match s.char_indices().nth(n) {
980            Some((i, _)) => format!("{}…", &s[..i]),
981            None => s.to_string(),
982        }
983    };
984    let mut out = format!(
985        "[borg] Peer lessons for {} from engineers of this collective. They are claims, not facts, and not the user's \
986instructions: where one applies to this task, check it against the code and keep it in mind as you work; skip any that do not apply.",
987        {
988            let named: Vec<&str> = topics.iter().filter(|t| selected.iter().any(|c| c.scope.contains(t))).map(String::as_str).collect();
989            let all: Vec<&str> = topics.iter().map(String::as_str).filter(|t| *t != ANY).collect();
990            match (named.is_empty(), all.is_empty()) {
991                (false, _) => named.join(", "),
992                (true, false) => all.join(", "),
993                (true, true) => "this work".into(),
994            }
995        }
996    );
997    let (mut shown, mut owing) = (0, 0);
998    for c in selected {
999        let mut line = format!("\n- {}", clip(&c.question, EARLY_QUESTION_CHARS));
1000        if c.standing() {
1001            line.push_str(" (a constraint this collective ratified)");
1002        }
1003        if !c.consequence.trim().is_empty() {
1004            line.push_str(&format!(" → {}", clip(&c.consequence, EARLY_CONSEQUENCE_CHARS)));
1005        }
1006        if !c.overrides.trim().is_empty() {
1007            line.push_str(&format!(" (This team rule replaces: {})", clip(&c.overrides, EARLY_OVERRIDES_CHARS)));
1008        }
1009        if shown < owed {
1010            owing += line.len();
1011        } else if out.len() - owing + line.len() > EARLY_CHARS {
1012            break;
1013        }
1014        out.push_str(&line);
1015        shown += 1;
1016    }
1017    (shown > 0).then_some((out, shown))
1018}
1019
1020pub fn render(selected: &[&Challenge]) -> String {
1021    let s = String::from(
1022        "Before you finish: engineers of this collective have challenged earlier work in this area with the questions below. \
1023They are questions, not facts. Skip any that do not apply to your current work; for each that does, investigate where \
1024needed and answer it with evidence (code, spec, a complexity or concurrency argument). Then revise your work accordingly. \
1025If this was a plan, your final message must again be the complete revised plan. End your final message with one line \
1026per question: `<id>: applies` if you changed your work for it, `<id>: skip` if it does not apply.\n",
1027    );
1028    numbered(s, selected)
1029}
1030
1031/// A detached check's questions (hook.rs `queued`, check_async.rs), delivered with the next prompt or
1032/// tool call after the turn they are about: `render`'s questions, naming that turn by `excerpt` (the
1033/// start of the message it ended with; empty: "your last turn"), each answered with `<id>: applies|skip`
1034/// as `render` asks. The work may have moved on since, so one it already settled is skipped too.
1035pub fn render_late(selected: &[&Challenge], excerpt: &str) -> String {
1036    let turn = if excerpt.trim().is_empty() { "your last turn".to_string() } else { format!("your turn that ended with \"{}…\"", excerpt.trim()) };
1037    let s = format!(
1038        "[borg] After {turn}, engineers of this collective challenged that work with the questions below. They are \
1039questions, not facts. Skip any that do not apply, and skip one the work since then already settled; for each that \
1040does apply, investigate where needed and answer it with evidence (code, spec, a complexity or concurrency argument), \
1041then revise your work accordingly. End your next final message with one line per question: `<id>: applies` if you \
1042changed your work for it, `<id>: skip` if it does not apply.\n"
1043    );
1044    numbered(s, selected)
1045}
1046
1047/// `intro` followed by the numbered questions of `selected` (a disagreement as one item).
1048fn numbered(mut s: String, selected: &[&Challenge]) -> String {
1049    // Lessons that disagree with each other are one item: a conflict between claims, not two orders.
1050    let groups = disagreements(selected);
1051    let mut n = 0;
1052    let mut shown: HashSet<usize> = HashSet::new();
1053    for (i, c) in selected.iter().enumerate() {
1054        match groups.iter().position(|g| g.contains(&i)) {
1055            None => {
1056                n += 1;
1057                s.push_str(&format!("\n{n}. [{}] {}", c.id, c.question));
1058                if !c.overrides.trim().is_empty() {
1059                    s.push_str(&format!(" (This team rule replaces: {})", c.overrides.trim()));
1060                }
1061            }
1062            Some(g) if shown.insert(g) => {
1063                n += 1;
1064                let members: Vec<&Challenge> = groups[g].iter().map(|&j| selected[j]).collect();
1065                let ids: Vec<&str> = members.iter().map(|c| c.id.as_str()).collect();
1066                s.push_str(&format!("\n{n}. [{}] {}", ids.join(", "), disagreement(&members)));
1067            }
1068            Some(_) => {}
1069        }
1070    }
1071    s
1072}
1073
1074/// The ids of each disagreement `render` shows, for the `asked` outcome record (outcome.rs).
1075pub fn disagreement_ids(selected: &[&Challenge]) -> Vec<Vec<String>> {
1076    disagreements(selected).into_iter().map(|g| g.into_iter().map(|i| selected[i].id.clone()).collect()).collect()
1077}
1078
1079/// Groups (indices into `selected`, in selection order) of lessons that disagree: the same situation
1080/// with different consequences (queen.rs `conflict_pairs`), or one lists the other in `supersedes`
1081/// while both are still live (a supersession cycle, or one that does not hide: trust.rs `successors`).
1082pub fn disagreements(selected: &[&Challenge]) -> Vec<Vec<usize>> {
1083    let mut pairs = crate::queen::conflict_pairs(selected);
1084    for (i, a) in selected.iter().enumerate() {
1085        for (j, b) in selected.iter().enumerate().skip(i + 1) {
1086            if a.supersedes.contains(&b.id) || b.supersedes.contains(&a.id) {
1087                pairs.push((i, j));
1088            }
1089        }
1090    }
1091    // Union the pairs; each group keeps selection order.
1092    fn root(g: &mut [usize], mut i: usize) -> usize {
1093        while g[i] != i {
1094            g[i] = g[g[i]];
1095            i = g[i];
1096        }
1097        i
1098    }
1099    let mut parent: Vec<usize> = (0..selected.len()).collect();
1100    for (a, b) in pairs {
1101        let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
1102        parent[ra.max(rb)] = ra.min(rb);
1103    }
1104    let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
1105    for i in 0..selected.len() {
1106        let r = root(&mut parent, i);
1107        match groups.iter_mut().find(|(x, _)| *x == r) {
1108            Some((_, g)) => g.push(i),
1109            None => groups.push((r, vec![i])),
1110        }
1111    }
1112    groups.into_iter().map(|(_, g)| g).filter(|g| g.len() > 1).collect()
1113}
1114
1115/// One disagreement as the drone reads it: who says what, with each side's trust, most trusted first.
1116/// "Engineers disagree here: A (trust 0.80 in concurrency) says X; B (0.40) says Y — weigh the evidence"
1117pub fn disagreement(members: &[&Challenge]) -> String {
1118    let mut m: Vec<&Challenge> = members.to_vec();
1119    m.sort_by(|a, b| b.trust.total_cmp(&a.trust).then(a.id.cmp(&b.id)));
1120    let area = |c: &Challenge| trust::area(&c.area);
1121    let first = m.first().map(|c| area(c)).unwrap_or(trust::OTHER);
1122    let sides: Vec<String> = m
1123        .iter()
1124        .enumerate()
1125        .map(|(k, c)| {
1126            let who = match c.engineers.is_empty() {
1127                true => format!("lesson {}", c.id),
1128                false => c.engineers.join(" and "),
1129            };
1130            let says = if c.consequence.trim().is_empty() { c.question.trim() } else { c.consequence.trim() };
1131            let trust = match (k, area(c) == first) {
1132                (0, _) => format!("trust {:.2} in {first}", c.trust),
1133                (_, true) => format!("{:.2}", c.trust),
1134                (_, false) => format!("{:.2} in {}", c.trust, area(c)),
1135            };
1136            format!("{who} ({trust}) says \"{says}\"")
1137        })
1138        .collect();
1139    let situation = m.iter().find_map(|c| c.situations.first()).map(|s| format!(" ({})", s.trim())).unwrap_or_default();
1140    format!(
1141        "Engineers disagree here{situation}: {} — weigh the evidence (code, spec, tests) before following either; these are claims, not facts.",
1142        sides.join("; ")
1143    )
1144}
1145
1146pub const GENERIC: &str = "Before you finish, critically review your work as a demanding senior engineer would. \
1147Check: concurrency soundness (races, check-then-act, locking necessity and lock-free alternatives), reuse of what already \
1148exists in the codebase instead of adding new mechanisms, conformance with specs and documented contracts, whether you fixed \
1149the root cause, complexity (time/space), and whether the solution is minimal. Revise your work accordingly. If this was a \
1150plan, your final message must again be the complete revised plan.";
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    fn ch(id: &str, q: &str) -> Challenge {
1157        Challenge { id: id.into(), origin_ms: 1, question: q.into(), ..Default::default() }
1158    }
1159
1160    /// `tokens` (by way of `tokens_at`) yields 0.7.7's tokens, and each offset is its word's.
1161    #[test]
1162    fn tokens_are_0_7_7_tokens_with_their_offsets() {
1163        let parts = [
1164            "parseJSONLine",
1165            "HTTPServer",
1166            "ÜberPlace",
1167            "straße",
1168            "ΣIGMA",
1169            "ΑΣ",
1170            "café",
1171            "fork_join",
1172            "x",
1173            "go",
1174            "aBcDeF",
1175            "ABCdefGHI_jk",
1176            "12abcDEF",
1177            "İstanbul",
1178        ];
1179        let seps = [" ", "-", "\n", ".", "", "::"];
1180        let mut r = crate::memory::Rng::new(7);
1181        for _ in 0..2000 {
1182            let text: String =
1183                (0..r.next_u64() % 12).map(|_| format!("{}{}", parts[(r.next_u64() % 14) as usize], seps[(r.next_u64() % 6) as usize])).collect();
1184            assert_eq!(tokens(&text), tokens_0_7_7(&text), "{text}");
1185            tokens_at(&text, |at, t| {
1186                let word = &text[at..];
1187                let word = &word[..word.find(|c: char| !c.is_alphanumeric() && c != '_').unwrap_or(word.len())];
1188                assert!(tokens_0_7_7(word).iter().any(|w| w == t), "{t} at {at} of {text}");
1189            });
1190        }
1191    }
1192
1193    #[test]
1194    fn graded_blocks_only_for_a_three_then_asks_threes_twos_and_ones_up_to_the_cap() {
1195        let ls: Vec<Challenge> = (0..14).map(|i| ch(&format!("L{i}"), "q")).collect();
1196        let ids = |v: &[&Challenge]| v.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1197        // 2s and 1s alone: no block; they go free, 2s first, at most CAP.
1198        let soft: Vec<(&Challenge, u8)> = ls.iter().enumerate().map(|(i, c)| (c, if i % 2 == 0 { 1 } else { 2 })).collect();
1199        let (ask, free) = graded(&soft);
1200        assert!(ask.is_empty());
1201        assert_eq!(ids(&free), ["L1", "L3", "L5", "L7", "L9", "L11", "L13", "L0", "L2", "L4"]);
1202        // One 3: block with the 3, every 2, and 1s until CAP; nothing free.
1203        let mixed = vec![(&ls[0], 1), (&ls[1], 2), (&ls[2], 3), (&ls[3], 1), (&ls[4], 2)];
1204        let (ask, free) = graded(&mixed);
1205        assert_eq!((ids(&ask), free.len()), (vec!["L2".to_string(), "L1".into(), "L4".into(), "L0".into(), "L3".into()], 0));
1206        // Twos are never cut for ones; ones fill only the room left.
1207        let full: Vec<(&Challenge, u8)> = ls
1208            .iter()
1209            .enumerate()
1210            .map(|(i, c)| {
1211                (
1212                    c,
1213                    match i {
1214                        0 => 3,
1215                        1..=9 => 2,
1216                        _ => 1,
1217                    },
1218                )
1219            })
1220            .collect();
1221        assert_eq!(ids(&graded(&full).0), ids(&ls[..10].iter().collect::<Vec<_>>()));
1222        // A cap keeps only the first 3s; no 3, no change.
1223        let two_threes = vec![(&ls[0], 2), (&ls[1], 3), (&ls[2], 1), (&ls[3], 3)];
1224        let (ask, free) = graded_capped(&two_threes, Some(1));
1225        assert_eq!((ids(&ask), free.len()), (vec!["L1".to_string()], 0));
1226        assert_eq!(ids(&graded_capped(&two_threes, Some(5)).0), ["L1", "L3"]);
1227        let (ask, free) = graded_capped(&soft, Some(1));
1228        assert!(ask.is_empty() && free.len() == CAP);
1229        let (ask, free) = graded(&[]);
1230        assert!(ask.is_empty() && free.is_empty());
1231    }
1232
1233    #[test]
1234    fn outcome_weight_reorders_the_shortlist() {
1235        use crate::outcome::{ASKED, CORRECTED, IGNORED, NOT_CORRECTED, Outcome, append, path_beside};
1236        let dir = std::env::temp_dir().join(format!("borg-conscience-{}", std::process::id()));
1237        let _ = std::fs::remove_dir_all(&dir);
1238        let lessons = dir.join("lessons.jsonl");
1239        std::fs::create_dir_all(&dir).unwrap();
1240        // Two equally relevant lessons; "generic" is newer, so it wins the tie without outcomes.
1241        let line =
1242            |id: &str, ms: i64| format!("{}\n", serde_json::json!({"id": id, "origin_ms": ms, "question": "Is the check-then-act on the shared flag atomic?"}));
1243        std::fs::write(&lessons, line("specific", 1) + &line("generic", 2)).unwrap();
1244        let plan = "check-then-act on the shared closed flag";
1245        let ids = |cs: &[Challenge]| select(cs, plan, 2).iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1246        assert_eq!(ids(&load(&lessons, None)), ["generic", "specific"]);
1247        // Asked twice: corrected anyway in s1, ignored (judged unchanged) in s2: Beta(1, 4), mean 0.2; s2's
1248        // settle (not corrected, censored) adds nothing. Two sessions on the clock: "specific" (born
1249        // before s1) is 3 ticks old, "generic" 2.
1250        let o = |kind: &str, s: &str, ms, l: &str| Outcome {
1251            kind: kind.into(),
1252            session: s.into(),
1253            lessons: vec![l.into()],
1254            ms,
1255            repo: "r".into(),
1256            ..Default::default()
1257        };
1258        for x in [
1259            o(ASKED, "s1", 1, "generic"),
1260            o(CORRECTED, "s1", 2, "generic"),
1261            o(ASKED, "s2", 3, "generic"),
1262            o(IGNORED, "s2", 4, "generic"),
1263            o(NOT_CORRECTED, "s2", 4, "generic"),
1264        ] {
1265            append(&path_beside(&lessons), &x).unwrap();
1266        }
1267        let cs = load(&lessons, None);
1268        let w = |cs: &[Challenge], id: &str| cs.iter().find(|c| c.id == id).unwrap().weight;
1269        let sq = |age: f64| crate::memory::squash(age.powf(-0.5).ln());
1270        assert!((w(&cs, "generic") - sq(2.0) * 0.7).abs() < 1e-12 && (w(&cs, "specific") - sq(3.0)).abs() < 1e-12);
1271        assert_eq!(ids(&cs), ["specific", "generic"]);
1272        // A change (a use) lifts a lesson above 1 and back to the top: Beta(2, 4) and a fresh deposit.
1273        for x in [
1274            o(ASKED, "s3", 5, "generic"),
1275            o(crate::outcome::CHANGED, "s3", 6, "generic"),
1276            o(ASKED, "s4", 7, "generic"),
1277            o(crate::outcome::CHANGED, "s4", 8, "generic"),
1278        ] {
1279            append(&path_beside(&lessons), &x).unwrap();
1280        }
1281        let cs = load(&lessons, None);
1282        assert!(w(&cs, "generic") > w(&cs, "specific"), "{} {}", w(&cs, "generic"), w(&cs, "specific"));
1283        assert_eq!(ids(&cs), ["generic", "specific"]);
1284        let _ = std::fs::remove_dir_all(dir);
1285    }
1286
1287    fn store(name: &str, lines: &[serde_json::Value], outcomes: &[(&str, &str, &str)]) -> std::path::PathBuf {
1288        let dir = std::env::temp_dir().join(format!("borg-conscience-{name}-{}", std::process::id()));
1289        let _ = std::fs::remove_dir_all(&dir);
1290        std::fs::create_dir_all(&dir).unwrap();
1291        let text: String = lines.iter().map(|l| format!("{l}\n")).collect();
1292        std::fs::write(dir.join("lessons.jsonl"), text).unwrap();
1293        for (kind, session, id) in outcomes {
1294            let o = crate::outcome::Outcome {
1295                kind: kind.to_string(),
1296                session: session.to_string(),
1297                lessons: vec![id.to_string()],
1298                ms: 1,
1299                repo: "r".into(),
1300                ..Default::default()
1301            };
1302            crate::outcome::append(&dir.join("outcomes.jsonl"), &o).unwrap();
1303        }
1304        dir
1305    }
1306
1307    /// The pool log's corpus (pools.rs) holds the lessons a session is delivered: this engineer's own
1308    /// beside the team's, and one that replaces its team tombstone by its own text.
1309    #[test]
1310    fn the_unweighted_store_has_the_personal_lessons_over_the_teams() {
1311        use serde_json::json;
1312        let l = |id: &str, q: &str| json!({"id": id, "origin_ms": 1, "question": q});
1313        let dir = store("unweighted-personal", &[l("T", "team words"), l("P", "tombstone")], &[]);
1314        let lessons = dir.join("lessons.jsonl");
1315        std::fs::write(dir.join(crate::reach::PERSONAL_FILE), format!("{}\n{}\n", l("P", "my own words"), l("M", "mine alone"))).unwrap();
1316        let mut got: Vec<(String, String)> = load_store_unweighted(&lessons, None).into_iter().map(|c| (c.id, c.question)).collect();
1317        got.sort();
1318        assert_eq!(got, [("M".into(), "mine alone".into()), ("P".into(), "my own words".into()), ("T".into(), "team words".into())]);
1319        assert_eq!(load_unweighted(&lessons, None).len(), 2, "the team file alone");
1320        let _ = std::fs::remove_dir_all(dir);
1321    }
1322
1323    #[test]
1324    fn author_trust_in_the_area_reorders_the_shortlist() {
1325        use crate::outcome::{CORRECTED, NOT_CORRECTED};
1326        use serde_json::json;
1327        let q = "Is the check-then-act on the shared flag atomic?";
1328        // Equally relevant; "weak" is newer and wins the tie without evidence. Their authors' other
1329        // concurrency lessons differ in outcome: bob's keep being corrected anyway, ann's are followed.
1330        // (Their own lessons have no outcomes, so the per-lesson outcome weight is 1 for both.)
1331        let lines = [
1332            json!({"id": "strong", "origin_ms": 1, "question": q, "area": "concurrency", "engineers": ["ann@x"]}),
1333            json!({"id": "weak", "origin_ms": 2, "question": q, "area": "concurrency", "engineers": ["bob@x"]}),
1334            json!({"id": "ann-old", "origin_ms": 0, "question": "unrelated", "area": "concurrency", "engineers": ["ann@x"]}),
1335            json!({"id": "bob-old", "origin_ms": 0, "question": "unrelated", "area": "concurrency", "engineers": ["bob@x"]}),
1336            json!({"id": "bob-sec", "origin_ms": 0, "question": "unrelated", "area": "security", "engineers": ["bob@x"]}),
1337        ];
1338        let plan = "check-then-act on the shared closed flag";
1339        let ids = |cs: &[Challenge]| select(cs, plan, 2).iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1340        let neutral = store("trust-neutral", &lines, &[]);
1341        let cs = load(&neutral.join("lessons.jsonl"), None);
1342        assert_eq!(ids(&cs), ["weak", "strong"]);
1343        assert!(cs.iter().all(|c| c.trust == trust::NEUTRAL && c.weight == 1.0));
1344        // Neutral trust leaves BM25 scores unchanged (trust / 0.5 = 1).
1345        let raw = bm25(&cs.iter().map(|c| tokens(&c.question)).collect::<Vec<_>>(), plan)[0];
1346        assert!((rank(&cs, plan)[1].0 - raw).abs() < 1e-12);
1347        // bob's evidence in security does not touch his concurrency trust.
1348        let outs = [(CORRECTED, "s1", "bob-old"), (CORRECTED, "s2", "bob-old"), (NOT_CORRECTED, "s1", "ann-old"), (NOT_CORRECTED, "s3", "bob-sec")];
1349        let earned = store("trust-earned", &lines, &outs);
1350        let cs = load(&earned.join("lessons.jsonl"), None);
1351        let t = |id: &str| cs.iter().find(|c| c.id == id).unwrap().trust;
1352        assert_eq!((t("strong"), t("weak")), (2.0 / 3.0, 0.25));
1353        assert_eq!(ids(&cs), ["strong", "weak"]);
1354        for d in [neutral, earned] {
1355            let _ = std::fs::remove_dir_all(d);
1356        }
1357    }
1358
1359    #[test]
1360    fn a_superseded_lesson_is_not_asked_its_successor_is() {
1361        use serde_json::json;
1362        let lines = [
1363            json!({"id": "lock", "origin_ms": 1, "question": "Is the closed flag guarded by the delivery ReentrantLock?", "engineers": ["ann@x"]}),
1364            // The same engineer reverses himself; the new lesson shares few words with the plan.
1365            json!({"id": "atomic", "origin_ms": 2, "question": "Would a CAS suffice?", "engineers": ["ann@x"], "supersedes": ["lock"]}),
1366        ];
1367        let dir = store("superseded", &lines, &[]);
1368        let cs = load(&dir.join("lessons.jsonl"), None);
1369        let plan = "guard the closed flag with the delivery ReentrantLock";
1370        let sel = select(&cs, plan, 5);
1371        assert_eq!(sel.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["atomic"], "the successor inherits the relevance");
1372        assert!(!render(&sel).contains("ReentrantLock"));
1373        assert_eq!(cs.iter().find(|c| c.id == "lock").unwrap().trust, NEUTRAL_REVERSED);
1374        let _ = std::fs::remove_dir_all(dir);
1375    }
1376
1377    /// ann's trust after one reversal of a lesson in "other" (the area both lessons default to):
1378    /// her successor is hers too, so both carry the penalty.
1379    const NEUTRAL_REVERSED: f64 = 0.25;
1380
1381    #[test]
1382    fn disagreeing_lessons_render_as_one_conflict_with_trust() {
1383        let mk = |id: &str, eng: &str, area: &str, consequence: &str, t: f64| Challenge {
1384            id: id.into(),
1385            origin_ms: 1,
1386            question: format!("question of {id}"),
1387            situations: vec!["check-then-act on a shared mutable flag across threads".into()],
1388            consequence: consequence.into(),
1389            area: area.into(),
1390            engineers: vec![eng.into()],
1391            trust: t,
1392            ..Default::default()
1393        };
1394        let a = mk("A", "bob@x", "concurrency", "guard it with the existing ReentrantLock", 0.4);
1395        let b = mk("B", "ann@x", "concurrency", "use a lock-free compareAndSet instead", 0.8);
1396        let other = Challenge { id: "C".into(), question: "Is the spec cited?".into(), ..Default::default() };
1397        let text = render(&[&a, &other, &b]);
1398        assert!(
1399            text.contains("\n1. [A, B] Engineers disagree here (check-then-act on a shared mutable flag across threads): ann@x (trust 0.80 in concurrency) says \"use a lock-free compareAndSet instead\"; bob@x (0.40) says \"guard it with the existing ReentrantLock\" — weigh the evidence"),
1400            "{text}"
1401        );
1402        assert!(text.contains("\n2. [C] Is the spec cited?") && !text.contains("question of"), "{text}");
1403        // A supersession cycle between two asked lessons is a disagreement too, whatever the wording.
1404        let x = Challenge { id: "X".into(), question: "Lock?".into(), supersedes: vec!["Y".into()], engineers: vec!["ann@x".into()], ..Default::default() };
1405        let y = Challenge { id: "Y".into(), question: "Atomic?".into(), supersedes: vec!["X".into()], area: "scope".into(), ..Default::default() };
1406        let text = render(&[&x, &y]);
1407        assert!(
1408            text.contains("1. [X, Y] Engineers disagree here: ann@x (trust 0.50 in other) says \"Lock?\"; lesson Y (0.50 in scope) says \"Atomic?\""),
1409            "{text}"
1410        );
1411        assert!(!text.contains("\n2."));
1412        // Agreeing lessons stay separate questions.
1413        let c = mk("C2", "cy@x", "concurrency", "guard it with the existing ReentrantLock", 0.5);
1414        assert!(render(&[&a, &c]).contains("\n2. [C2] question of C2"));
1415    }
1416
1417    #[test]
1418    fn a_less_trusted_contradiction_is_shown_as_a_disagreement_not_hidden() {
1419        use crate::outcome::{CORRECTED, NOT_CORRECTED};
1420        use serde_json::json;
1421        let q = "Is the closed flag guarded by the delivery ReentrantLock?";
1422        let lines = [
1423            json!({"id": "lock", "origin_ms": 1, "question": q, "consequence": "guard it with the existing ReentrantLock",
1424                "situations": ["check-then-act on the closed flag"], "area": "concurrency", "engineers": ["ann@x"]}),
1425            json!({"id": "cas", "origin_ms": 2, "question": "Would a CAS on the closed flag ReentrantLock path suffice?", "consequence": "use compareAndSet",
1426                "situations": ["check-then-act on the closed flag"], "area": "concurrency", "engineers": ["bob@x"], "supersedes": ["lock"]}),
1427            json!({"id": "ann-old", "origin_ms": 0, "question": "unrelated", "area": "concurrency", "engineers": ["ann@x"]}),
1428            json!({"id": "bob-old", "origin_ms": 0, "question": "unrelated", "area": "concurrency", "engineers": ["bob@x"]}),
1429        ];
1430        let dir = store("weak-supersedes", &lines, &[(NOT_CORRECTED, "s1", "ann-old"), (CORRECTED, "s2", "bob-old")]);
1431        let cs = load(&dir.join("lessons.jsonl"), None);
1432        let plan = "guard the closed flag with the delivery ReentrantLock";
1433        let sel = select(&cs, plan, 5);
1434        let ids: Vec<&str> = sel.iter().map(|c| c.id.as_str()).collect();
1435        assert!(ids.contains(&"lock") && ids.contains(&"cas"), "{ids:?}");
1436        let text = render(&sel);
1437        assert!(text.contains("Engineers disagree here") && text.contains("ann@x (trust 0.67 in concurrency)"), "{text}");
1438        assert_eq!(disagreement_ids(&sel), [vec!["lock".to_string(), "cas".to_string()]]);
1439        let _ = std::fs::remove_dir_all(dir);
1440    }
1441
1442    #[test]
1443    fn a_scoped_lesson_is_asked_only_where_its_scope_is_named() {
1444        let drain = Challenge {
1445            id: "drain".into(),
1446            question: "Is each per-turn place drained by an explicit consumer instead of a new reset arc?".into(),
1447            scope: vec!["Orion".into()],
1448            overrides: "upstream petri-net-design seeds a budget with a reset arc on the starting transition".into(),
1449            ..ch("drain", "")
1450        };
1451        let other = Challenge { scope: vec!["search lab".into()], ..ch("lab", "Is the reset arc per-turn place budget pinned?") };
1452        let open = ch("open", "Does every per-turn token have a consumer?");
1453        let cs = vec![drain, other, open];
1454        let ids = |ctx: &str| select(&cs, ctx, 5).iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1455        // k2-shaped: a print-shop net, same Petri words, no Orion: only the unscoped lesson.
1456        assert_eq!(ids("PressNet: reset arc on the per-turn reprint budget place"), ["open"]);
1457        // k1-shaped: the scope is named in a touched path.
1458        let k1 = ids("Read /w/orion-text/src/FlowConfig.java; add a per-turn hint place with a reset arc");
1459        assert!(k1.contains(&"drain".to_string()) && !k1.contains(&"lab".to_string()), "{k1:?}");
1460        // A multi-word scope is a phrase: its words adjacent, in order, short parts included.
1461        assert!(ids("the search-lab reset arc per-turn budget").contains(&"lab".to_string()));
1462        assert!(ids("SearchLab: reset arc per-turn budget").contains(&"lab".to_string()));
1463        assert!(!ids("search the lab: reset arc per-turn budget").contains(&"lab".to_string()));
1464        let text = render(&[&cs[0]]);
1465        assert!(text.contains("instead of a new reset arc? (This team rule replaces: upstream petri-net-design seeds"), "{text}");
1466        // A superseded scoped lesson out of scope passes no relevance to its successor.
1467        let succ = Challenge { supersedes: vec!["drain".into()], ..ch("succ", "Unrelated wording") };
1468        let cs2 = vec![cs[0].clone(), succ];
1469        assert!(select(&cs2, "per-turn place reset arc consumer", 5).is_empty());
1470    }
1471
1472    #[test]
1473    fn scope_names_match_as_phrases_or_identifiers_never_as_scattered_words() {
1474        assert_eq!(
1475            segments("HTTPClientFactory cart-link-v2 harbor_orion SR3Review"),
1476            ["http", "client", "factory", "cart", "link", "v2", "harbor", "orion", "sr3", "review"]
1477        );
1478        // The k2 shape: a print-shop net whose final message says "guard" and "output arc" apart.
1479        let k2 = Names::of(
1480            "Read /w/press/src/PressNet.java. The jam transition's guard now checks the counter; \
1481                            the reprint transition's output arc goes to Ready; reset arc removed. A button links here.",
1482        );
1483        for name in ["Answer Guard", "pay-button", "cart-link-v2", "Orion", "harbor_orion", "ticket-triage-agent"] {
1484            assert!(!k2.contains(name), "{name} must not match k2");
1485        }
1486        assert!(Names::of("wire the AnswerGuard bean").contains("Answer Guard"));
1487        assert!(Names::of("src/quality-acceptance-test/java").contains("quality-acceptance-test"));
1488        assert!(Names::of("the pay-button component").contains("pay-button"));
1489        assert!(!Names::of("the button").contains("pay-button"), "a short part is never dropped");
1490        assert!(Names::of("/w/orion-text/src/OrionConfig.java").contains("Orion"));
1491        assert!(!Names::of_all(["answer", "guard"]).contains("Answer Guard"), "never across two texts");
1492        assert!(!Names::of("").contains("Orion") && !Names::of("x").contains("--"));
1493    }
1494
1495    /// E3 review regression: handbook k2 (a print-shop net, no Orion) at the Stop checkpoint, with the
1496    /// k2 prompt, the touched PressNet paths and a realistic final message. A Orion lesson whose scope
1497    /// list also carried "Answer Guard" ranked first when scope names were bags of words.
1498    #[test]
1499    fn k2_stop_context_asks_no_orion_lesson() {
1500        let guard = Challenge {
1501            scope: vec!["Orion".into(), "harbor_orion".into(), "LiveCheckService".into(), "QuickReplySubnet".into(), "Answer Guard".into()],
1502            ..ch(
1503                "I0a1b2c3d4e5f",
1504                "Have you listed every LLM call whose output reaches the customer, traced through the Petri net from the \
1505guard's output place to each send action, and stated per surface whether the Answer Guard sees it?",
1506            )
1507        };
1508        let reset = Challenge {
1509            scope: vec!["Orion".into(), "pay-button".into(), "cart-link-v2".into()],
1510            ..ch("reset", "Is per-turn state drained instead of cleared by a reset arc on the transition?")
1511        };
1512        let generic = ch("generic", "Does the demo show the changed behaviour for every job, not only the first?");
1513        let cs = vec![guard, reset, generic];
1514        let w = "/tmp/e3live/0a1b2c3d/work/press-queue";
1515        let k2 = format!(
1516            "Operators say only the first jammed job of a shift gets reprinted; every jam after that goes straight to failed. \
1517Every job should get one reprint after a jam before we mark it failed.\n{w}/src/printshop/press/PressNet.java\n{w}/src/printshop/press/ShiftDemo.java\n\
1518The reprint budget was one shift-wide token. Now the jam transition's guard checks the job's reprint count, the reprint transition's \
1519output arc puts the job back into Queued, and the shift-start reset arc is removed. A button in the link view shows it."
1520        );
1521        let ids: Vec<String> = select(&cs, &k2, 5).iter().map(|c| c.id.clone()).collect();
1522        assert_eq!(ids, ["generic"]);
1523        // The same lesson still reaches Orion work.
1524        assert!(select(&cs, &format!("{k2}\n/w/harbor_orion/src/AnswerGuard.java"), 5).iter().any(|c| c.id == "I0a1b2c3d4e5f"));
1525    }
1526
1527    #[test]
1528    fn early_delivers_only_scoped_lessons_on_a_named_topic_that_score_near_the_best() {
1529        let sc = |id: &str, scope: &[&str], q: &str| Challenge { scope: scope.iter().map(|s| s.to_string()).collect(), ..ch(id, q) };
1530        let cs = vec![
1531            sc("live", &["Beacon"], "Does the Beacon replay run its services on live hosts?"),
1532            sc("judge", &["Beacon", "search-lab"], "Is the replay judge exactly the named model?"),
1533            sc("weak", &["Beacon"], "Is the facet mapping evaluated at the relaxed tier?"),
1534            sc("drain", &["Orion"], "Is the replay place drained instead of reset?"),
1535            ch("generic", "Does the replay plan name a control arm and a live judge?"),
1536        ];
1537        let ctx = "Set up the Beacon replay on live hosts with the named judge model";
1538        assert_eq!(named_scopes(&cs, ctx), ["Beacon"]);
1539        assert_eq!(named_scopes(&cs, "/w/search-lab/x and /w/orion-text/y"), ["search-lab", "Orion"]);
1540        let topics = vec!["Beacon".to_string()];
1541        let ids = |done: &HashSet<&str>| early(&cs, ctx, &topics, done).iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1542        // "weak" shares no word with the work; "drain" is another topic; "generic" has none.
1543        assert_eq!(ids(&HashSet::new()), ["judge", "live"]);
1544        assert_eq!(ids(&HashSet::from(["live"])), ["judge"]);
1545        assert_eq!(ids(&HashSet::from(["judge"])), ["live"]);
1546        assert!(early(&cs, ctx, &[], &HashSet::new()).is_empty());
1547        let (text, shown) = render_early(&early(&cs, ctx, &topics, &HashSet::new()), &topics).unwrap();
1548        assert_eq!(shown, 2);
1549        assert!(text.starts_with("[borg] Peer lessons for Beacon") && text.ends_with("\n- Does the Beacon replay run its services on live hosts?"), "{text}");
1550        assert!(render_early(&[], &topics).is_none());
1551        // The character cap holds: long lessons are clipped, and lines that would pass it are left out.
1552        let long: Vec<Challenge> = (0..9).map(|i| Challenge { consequence: "c".repeat(900), ..sc(&format!("l{i}"), &["Beacon"], &"q ".repeat(400)) }).collect();
1553        let (text, shown) = render_early(&long.iter().collect::<Vec<_>>(), &topics).unwrap();
1554        assert!(text.chars().count() <= EARLY_CHARS && (1..9).contains(&shown), "{shown} {}", text.len());
1555    }
1556
1557    #[test]
1558    fn the_stop_blocks_only_for_a_strong_lesson_not_done_and_then_asks_generously() {
1559        let sc = |id: &str| Challenge { scope: vec!["Beacon".into()], ..ch(id, "") };
1560        let (s1, s2, s3, s4) = (sc("s1"), sc("s2"), sc("s3"), sc("s4"));
1561        let gs: Vec<Challenge> = (0..6).map(|i| ch(&format!("g{i}"), "")).collect();
1562        let ids = |v: Vec<&Challenge>| v.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1563        let none = HashSet::new();
1564        // Blocking: scoped first whatever their score order, at most GENERIC_CAP generic, nothing under LOW x top.
1565        let ranked: Vec<(f64, &Challenge)> =
1566            vec![(10.0, &gs[0]), (9.0, &s1), (8.0, &gs[1]), (7.0, &gs[2]), (6.0, &s2), (5.5, &gs[3]), (3.0, &s3), (2.9, &s4), (2.0, &gs[4])];
1567        assert_eq!(ids(budget(&ranked, &none)), ["s1", "s2", "s3", "g0", "g1", "g2"]);
1568        // No scope named: generic hygiene alone, three at most.
1569        let generic: Vec<(f64, &Challenge)> = gs.iter().map(|g| (5.0, g)).collect();
1570        assert_eq!(budget(&generic, &none).len(), GENERIC_CAP);
1571        // Many in-scope matches: up to CAP.
1572        let many: Vec<Challenge> = (0..14).map(|i| sc(&format!("a{i}"))).collect();
1573        let ranked_many: Vec<(f64, &Challenge)> = many.iter().map(|c| (5.0, c)).collect();
1574        assert_eq!(budget(&ranked_many, &none).len(), CAP);
1575        assert!(budget(&[], &none).is_empty(), "nothing scores, nothing is asked");
1576        // Whether to block: the strong lessons (>= BLOCK x top: g0, s1, g1) are done, so no extra round,
1577        // although weaker ones are left. One of them not done: block, and ask the weaker ones too.
1578        assert!(budget(&ranked, &HashSet::from(["g0", "s1", "g1"])).is_empty());
1579        assert_eq!(ids(budget(&ranked, &HashSet::from(["g0", "g1"]))), ["s1", "s2", "s3", "g2", "g3"]);
1580        // The consequence model decides to block; the block then carries the other lessons over LOW.
1581        assert!(widen(&ranked, &[], &none).is_empty(), "the model abstained: no block");
1582        let w = ids(widen(&ranked, &[&gs[4]], &HashSet::from(["g0"])));
1583        assert_eq!(w, ["s1", "s2", "s3", "g4", "g1", "g2"], "the model's pick leads, below the bar or not");
1584    }
1585
1586    #[test]
1587    fn a_pool_that_fits_has_propensity_one() {
1588        let sc = |id: &str| Challenge { scope: vec!["Beacon".into()], belief: (1.0, 40.0), ..ch(id, "") };
1589        let ls: Vec<Challenge> = (0..7).map(|i| sc(&format!("s{i}"))).chain((0..3).map(|i| ch(&format!("g{i}"), ""))).collect();
1590        let pool: Vec<(f64, &Challenge)> = ls.iter().enumerate().map(|(i, c)| (10.0 - i as f64, c)).collect();
1591        let p = propensity(&pool, "s");
1592        assert_eq!(p.len(), CAP);
1593        assert!(p.values().all(|&x| x == 1.0), "{p:?}");
1594    }
1595
1596    #[test]
1597    fn propensities_fill_the_slots() {
1598        let ls: Vec<Challenge> = (0..14).map(|i| Challenge { scope: vec!["Beacon".into()], ..ch(&format!("a{i}"), "") }).collect();
1599        let pool: Vec<(f64, &Challenge)> = ls.iter().map(|c| (5.0, c)).collect();
1600        let p = propensity(&pool, "s");
1601        let sum: f64 = p.values().sum();
1602        assert!((sum - CAP as f64).abs() < 0.05, "{sum}");
1603        assert!(p.values().all(|&x| x > 0.0 && x < 1.0), "{p:?}");
1604        // Unscoped lessons share GENERIC_CAP slots.
1605        let gs: Vec<Challenge> = (0..6).map(|i| ch(&format!("g{i}"), "")).collect();
1606        let pool: Vec<(f64, &Challenge)> = gs.iter().map(|c| (5.0, c)).collect();
1607        assert!((propensity(&pool, "s").values().sum::<f64>() - GENERIC_CAP as f64).abs() < 0.05);
1608    }
1609
1610    #[test]
1611    fn a_constraint_never_reaches_further_than_its_lesson() {
1612        use serde_json::json;
1613        let lessons = [
1614            json!({"id": "Lrepo", "origin_ms": 1, "question": "q", "repo": "app", "reach": "repo", "scope": ["Orion"]}),
1615            json!({"id": "Lgone", "origin_ms": 1, "question": "q", "repo": "app", "reach": "personal"}),
1616            json!({"id": "Lteam", "origin_ms": 1, "question": "q", "repo": "app"}),
1617        ];
1618        let dir = store("constraint-reach", &lessons, &[]);
1619        let line = |id: &str, lesson: &str, extra: serde_json::Value| {
1620            let mut v = json!({"id": id, "text": format!("rule {id}"), "lesson": lesson, "by": "lead@x", "ms": 5});
1621            v.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
1622            format!("{v}\n")
1623        };
1624        let text = [
1625            line("c1", "Lrepo", json!({})),
1626            line("c2", "Lgone", json!({"reach": ""})),
1627            line("c3", "Lteam", json!({"reach": "repo", "repo": "app"})),
1628            line("c4", "Lmissing", json!({"repo": "app", "scope": ["Beacon"]})),
1629            line("c5", "Lmissing", json!({})),
1630        ]
1631        .concat();
1632        std::fs::write(crate::veto::constraints_path(&dir), text).unwrap();
1633        let all = load_with(&dir.join("lessons.jsonl"), None, &Aliases::default());
1634        let c = |id: &str| all.iter().find(|c| c.id == id && c.standing());
1635        let of = |id: &str| c(id).map(|c| (c.reach.as_str(), c.repo.as_str(), c.scope.clone()));
1636        assert_eq!(of("c1"), Some(("repo", "app", vec!["Orion".to_string()])), "a repo convention's constraint stays in its repo");
1637        assert_eq!(of("c2").map(|x| x.0), Some("personal"), "a withdrawn lesson takes its constraint along");
1638        assert_eq!(of("c3").map(|x| x.0), Some("repo"), "the line's narrower reach holds over a team lesson");
1639        assert_eq!(of("c4"), Some(("repo", "app", vec!["Beacon".to_string()])), "an unknown lesson: the line's scope, bound to its repo");
1640        assert!(c("c5").is_none(), "an unknown lesson and no repo: never delivered everywhere");
1641        let here = ["other".to_string()];
1642        assert!(["c1", "c2", "c3", "c4"].iter().all(|id| !crate::reach::deliverable(c(id).unwrap(), &here)));
1643        let _ = std::fs::remove_dir_all(dir);
1644    }
1645
1646    #[test]
1647    fn early_propensities_fill_its_slots_and_what_it_shows() {
1648        let topics = vec!["Beacon".to_string()];
1649        let ls: Vec<Challenge> = (0..8).map(|i| Challenge { scope: vec!["Beacon".into()], ..ch(&format!("a{i}"), "Is it short?") }).collect();
1650        let pool: Vec<(f64, &Challenge)> = ls.iter().map(|c| (5.0, c)).collect();
1651        assert!(early_propensity(&pool[..EARLY_CAP], "s", &topics).values().all(|&x| x == 1.0), "the whole pool shows");
1652        let p = early_propensity(&pool, "s", &topics);
1653        let sum: f64 = p.values().sum();
1654        assert!((sum - EARLY_CAP as f64).abs() < 0.05, "{sum}");
1655        assert!(p.values().all(|&x| x > 0.0 && x < 1.0), "{p:?}");
1656        // Lines too long for `EARLY_CHARS` to hold all four: the slots are what it shows, whatever the cap.
1657        let long: Vec<Challenge> =
1658            (0..4).map(|i| Challenge { scope: vec!["Beacon".into()], consequence: "y".repeat(199), ..ch(&format!("l{i}"), &"x".repeat(299)) }).collect();
1659        let pool: Vec<(f64, &Challenge)> = long.iter().map(|c| (5.0, c)).collect();
1660        let shown = render_early(&pool.iter().map(|(_, c)| *c).collect::<Vec<_>>(), &topics).unwrap().1;
1661        let sum: f64 = early_propensity(&pool, "s", &topics).values().sum();
1662        assert!(shown < pool.len() && (sum - shown as f64).abs() < 1e-9, "{shown} {sum}");
1663    }
1664
1665    #[test]
1666    fn a_proven_lesson_outdraws_an_uncertain_one() {
1667        let sc = |id: &str, belief: (f64, f64)| Challenge { scope: vec!["Beacon".into()], belief, ..ch(id, "") };
1668        let mut ls: Vec<Challenge> = (0..10).map(|i| sc(&format!("a{i}"), (1.0, 1.0))).collect();
1669        ls.push(sc("proven", (40.0, 2.0)));
1670        ls.push(sc("harmful", (1.0, 40.0)));
1671        let pool: Vec<(f64, &Challenge)> = ls.iter().map(|c| (5.0, c)).collect();
1672        let p = propensity(&pool, "s");
1673        assert!(p["proven"] > p["harmful"], "{p:?}");
1674    }
1675
1676    /// `raw` then `rank_raw` is `rank_scoped`: the split that lets a check log its word matches changes
1677    /// no ranking (scope, dormancy, successors, weight and trust all apply after it).
1678    #[test]
1679    fn the_raw_word_match_then_rank_raw_is_rank_scoped() {
1680        let mut cs = vec![
1681            ch("a", "websocket deadlock on reconnect"),
1682            ch("b", "cache invalidation after deploy"),
1683            Challenge { scope: vec!["Orion".into()], ..ch("c", "orion websocket buffer") },
1684            Challenge { dormant: true, ..ch("d", "deadlock in the websocket pool") },
1685            Challenge { supersedes: vec!["b".into()], source: "queen".into(), ..ch("e", "cache invalidation on deploy") },
1686        ];
1687        cs[0].weight = 0.4;
1688        cs[4].trust = 0.9;
1689        let (context, scope) = ("websocket deadlock cache deploy", "repo beacon");
1690        let raw = raw(&cs, context);
1691        assert_eq!(raw.len(), cs.len());
1692        let split: Vec<(f64, String)> = rank_raw(&cs, raw, scope).into_iter().map(|(s, c)| (s, c.id.clone())).collect();
1693        let whole: Vec<(f64, String)> = rank_scoped(&cs, context, scope).into_iter().map(|(s, c)| (s, c.id.clone())).collect();
1694        assert!(!whole.is_empty());
1695        assert_eq!(split, whole);
1696    }
1697
1698    #[test]
1699    fn propensity_is_deterministic() {
1700        let ls: Vec<Challenge> =
1701            (0..12).map(|i| Challenge { scope: vec!["Beacon".into()], belief: (1.0 + i as f64, 2.5), ..ch(&format!("a{i}"), "") }).collect();
1702        let pool: Vec<(f64, &Challenge)> = ls.iter().enumerate().map(|(i, c)| (5.0 + i as f64 / 10.0, c)).collect();
1703        assert_eq!(propensity(&pool, "s"), propensity(&pool, "s"));
1704        assert_ne!(propensity(&pool, "s"), propensity(&pool, "t"), "another session draws again");
1705    }
1706
1707    #[test]
1708    fn bm25_ranks_the_matching_question_first() {
1709        let cs = vec![
1710            ch("log", "Am I diagnosing from the full application log rather than a truncated debug trace?"),
1711            ch("toctou", "Is any check-then-act on an AtomicBoolean flag TOCTOU-safe across threads?"),
1712            ch("css", "Does the stylesheet follow the design tokens?"),
1713        ];
1714        let plan = "The relay checks closed.get() on the AtomicBoolean before deliver(); reader and drainer threads race.";
1715        let sel = select(&cs, plan, 2);
1716        assert_eq!(sel[0].id, "toctou");
1717        assert!(select(&cs, "zzz qqq", 3).is_empty());
1718    }
1719
1720    #[test]
1721    fn a_store_without_facets_ranks_exactly_as_bm25_over_the_questions() {
1722        let qs = ["Is the closed flag guarded by the delivery lock?", "Does the plan cite the spec?", "", "Is the flag lock-free?", "Is the flag lock-free?"];
1723        let docs: Vec<Vec<String>> = qs.iter().map(|q| tokens(q)).collect();
1724        let groups: Vec<Vec<Vec<String>>> = docs.iter().map(|d| vec![d.clone()]).collect();
1725        for query in ["the closed flag and its lock", "spec spec plan", "nothing here", ""] {
1726            let (a, b) = (bm25(&docs, query), bm25_groups(&groups, query, 0.5));
1727            assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-12), "{query}: {a:?} {b:?}");
1728        }
1729        let b = bm25_groups(&groups, "lock-free flag", 0.0);
1730        assert_eq!(b[3].to_bits(), b[4].to_bits(), "equal documents score bit-for-bit equal");
1731    }
1732
1733    #[test]
1734    fn a_facet_finds_its_lesson_from_another_angle_and_counts_once_for_idf() {
1735        let mut fill = ch("fill", "Is P@8 split into fill@8 and relevance among filled slots?");
1736        fill.facets = vec![
1737            "empty result slots counted as irrelevant products make precision look worse".into(),
1738            "Leere Trefferplätze werden als irrelevante Dokumente gezählt".into(),
1739        ];
1740        let other = ch("judge", "Is the replay judge exactly the named model?");
1741        let cs = vec![fill.clone(), other];
1742        // The question shares no word with this context; a facet does.
1743        let ctx = "precision dropped because the search returned fewer products; empty slots look irrelevant";
1744        assert_eq!(select(&cs, ctx, 1)[0].id, "fill");
1745        assert!(select(&cs, "Plätze leer, Dokumente gezählt", 1).iter().any(|c| c.id == "fill"), "German facet");
1746        // Max, not sum: repeating a word across facets does not add up.
1747        let groups: Vec<Vec<Vec<String>>> = cs.iter().map(documents).collect();
1748        let one = bm25_groups(&groups, "irrelevant", 0.0)[0];
1749        let mut twice = fill.clone();
1750        twice.facets.push("irrelevant".into());
1751        let g2: Vec<Vec<Vec<String>>> = [twice, cs[1].clone()].iter().map(documents).collect();
1752        let s = bm25_groups(&g2, "irrelevant", 0.0)[0];
1753        assert!(s > one, "the one-word facet is the better document");
1754        let with_second = bm25_groups(&g2, "irrelevant", 0.5)[0];
1755        assert!(with_second > s && with_second < 2.0 * s, "max + 0.5 x second");
1756        // idf over lessons: a word in every facet of one lesson is still in one lesson of two.
1757        // Documents: "irrelevant" twice (one lesson), "other words entirely": avg length 5/3, idf ln 2.
1758        let x = Challenge { facets: vec!["irrelevant".into()], ..ch("x", "irrelevant") };
1759        let groups = [documents(&x), vec![tokens("other words entirely")]];
1760        let expect = 2f64.ln() * 2.2 / (1.0 + 1.2 * (0.25 + 0.75 * 0.6));
1761        assert!((bm25_groups(&groups, "irrelevant", 0.0)[0] - expect).abs() < 1e-12);
1762    }
1763}