use std::collections::BTreeMap;
use crate::civil::{iso, week_start, Day};
use crate::json;
use crate::rules::{Rule, Stance};
use crate::shape::{self, Shape};
use crate::shell;
use crate::transcript::{self, Scan, ScanError, Stats};
const UNKNOWN_MODEL: &str = "(unnamed)";
pub struct Options {
pub window: usize,
}
impl Default for Options {
fn default() -> Self {
Options { window: 20 }
}
}
#[derive(Default, Clone)]
pub struct Cell {
pub firings: u32,
pub complied: u32,
pub ignored: u32,
pub unanswered: u32,
}
impl Cell {
pub fn answered(&self) -> u32 {
self.complied + self.ignored
}
pub fn share(&self) -> Option<f64> {
if self.answered() == 0 {
None
} else {
Some(f64::from(self.complied) / f64::from(self.answered()))
}
}
}
pub struct Subject {
pub id: &'static str,
pub stance: Stance,
pub cells: BTreeMap<(String, Day), Cell>,
}
impl Subject {
pub fn total(&self) -> Cell {
let mut t = Cell::default();
for c in self.cells.values() {
t.firings += c.firings;
t.complied += c.complied;
t.ignored += c.ignored;
t.unanswered += c.unanswered;
}
t
}
}
pub struct Report {
pub subjects: Vec<Subject>,
pub stats: Stats,
pub options: Options,
}
pub fn run(scan: &Scan, rules: &[&'static Rule], options: Options) -> Result<Report, ScanError> {
let watched: Vec<(&'static Rule, Stance)> = rules
.iter()
.map(|r| (*r, crate::stance::resolve(r)))
.filter(|(_, s)| *s != Stance::Deny)
.collect();
let mut cells: Vec<BTreeMap<(String, Day), Cell>> =
watched.iter().map(|_| BTreeMap::new()).collect();
let stats = transcript::for_each_transcript(scan, |records| {
let parsed: Vec<Option<(shell::Parsed, Shape)>> = records
.iter()
.map(|r| {
if r.tool != scan.tool || r.command.is_empty() {
return None;
}
let p = shell::lex(&r.command);
if matches!(p, shell::Parsed::Opaque(_)) {
return None;
}
let s = Shape::of(&p);
if s.is_empty() {
None
} else {
Some((p, s))
}
})
.collect();
for (i, record) in records.iter().enumerate() {
let Some((here, shape)) = &parsed[i] else {
continue;
};
let end = (i + 1 + options.window).min(records.len());
let next = parsed[i + 1..end]
.iter()
.flatten()
.find(|(_, other)| shape::distance(shape, other) <= shape::NEAR);
for (k, (rule, _)) in watched.iter().enumerate() {
if (rule.examine)(here).is_none() {
continue;
}
let model = if record.model.is_empty() {
UNKNOWN_MODEL.to_string()
} else {
record.model.clone()
};
let cell = cells[k].entry((model, week_start(record.day))).or_default();
cell.firings += 1;
match next {
None => cell.unanswered += 1,
Some((then, _)) if (rule.examine)(then).is_some() => cell.ignored += 1,
Some(_) => cell.complied += 1,
}
}
}
})?;
let mut subjects: Vec<Subject> = watched
.iter()
.zip(cells)
.map(|((rule, stance), cells)| Subject {
id: rule.id,
stance: *stance,
cells,
})
.collect();
subjects.sort_by_key(|s| (std::cmp::Reverse(s.stance), s.id));
subjects.retain(|s| s.total().firings > 0);
Ok(Report {
subjects,
stats,
options,
})
}
fn pct(share: Option<f64>) -> String {
match share {
Some(p) => format!("{:.0}%", p * 100.0),
None => "-".to_string(),
}
}
impl Report {
pub fn render(&self, roots: &transcript::Roots) -> String {
let mut s = String::new();
s.push_str(&format!(
"transcripts: {} ({} files, {} Bash calls, {} duplicates dropped)\n",
roots
.dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", "),
self.stats.files,
self.stats.calls,
self.stats.duplicates,
));
s.push_str(&format!(
"UTC weeks. A firing is answered when an equivalent command follows within {} tool\n\
call(s): COMPLIED when it no longer matches, IGNORED when it matches again.\n\
`observe` rules said nothing — their share is the rate the habit corrects on its\n\
own, and advice is worth its tokens only where it beats that. `deny` is left out:\n\
a refused command never ran. Replayed against the rules AS THEY STAND TODAY, which\n\
is not the same as what the model was told on the day.\n\n",
self.options.window
));
if self.subjects.is_empty() {
s.push_str("no rule fired over these transcripts.\n");
return s;
}
for subject in &self.subjects {
let t = subject.total();
s.push_str(&format!(
"{} [{}] — {} firings, {} answered, {} complied\n",
subject.id,
subject.stance.as_str(),
t.firings,
t.answered(),
pct(t.share()),
));
s.push_str(&format!(
" {:<24}{:<14}{:>8}{:>10}{:>10}{:>11}\n",
"model", "week starting", "firings", "answered", "ignored", "complied"
));
for ((model, week), c) in &subject.cells {
s.push_str(&format!(
" {:<24}{:<14}{:>8}{:>10}{:>10}{:>11}\n",
model,
iso(*week),
c.firings,
c.answered(),
c.ignored,
pct(c.share()),
));
}
s.push('\n');
}
s
}
pub fn to_json(&self, roots: &transcript::Roots) -> String {
let subjects: Vec<String> = self
.subjects
.iter()
.map(|subject| {
let weeks: Vec<String> = subject
.cells
.iter()
.map(|((model, week), c)| {
json::object(&[
json::string_field("model", model),
json::string_field("week", &iso(*week)),
json::int_field("firings", i64::from(c.firings)),
json::int_field("complied", i64::from(c.complied)),
json::int_field("ignored", i64::from(c.ignored)),
json::int_field("unanswered", i64::from(c.unanswered)),
json::string_field("complied_share", &pct(c.share())),
])
})
.collect();
let t = subject.total();
json::object(&[
json::string_field("id", subject.id),
json::string_field("stance", subject.stance.as_str()),
json::int_field("firings", i64::from(t.firings)),
json::int_field("answered", i64::from(t.answered())),
json::string_field("complied_share", &pct(t.share())),
format!("\"weeks\":{}", json::array(&weeks)),
])
})
.collect();
json::object(&[
json::string_array_field(
"transcripts",
&roots
.dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>(),
),
json::int_field("files", self.stats.files as i64),
json::int_field("calls", self.stats.calls as i64),
json::int_field("window", self.options.window as i64),
format!("\"rules\":{}", json::array(&subjects)),
json::string_field(
"note",
"replayed against the rules as they stand today, which is not what the model \
was told on the day; `observe` rows are the silent control",
),
])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unanswered_firing_is_neither_complied_nor_ignored() {
let c = Cell {
firings: 3,
complied: 0,
ignored: 0,
unanswered: 3,
};
assert_eq!(c.answered(), 0);
assert_eq!(c.share(), None, "unmeasured, not zero");
}
#[test]
fn the_share_is_over_answered_firings_only() {
let c = Cell {
firings: 10,
complied: 3,
ignored: 1,
unanswered: 6,
};
assert_eq!(c.share(), Some(0.75));
assert_eq!(pct(c.share()), "75%");
}
}