diff --git a/Cargo.lock b/Cargo.lock index dfad63ef..150cc35b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "agency-tools" -version = "0.8.8" +version = "0.8.9" dependencies = [ "derive_more 2.1.1", "dirs", @@ -364,11 +364,11 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "az-core" -version = "0.8.8" +version = "0.8.9" [[package]] name = "az-gui" -version = "0.8.8" +version = "0.8.9" dependencies = [ "agency-proxy-client", "agency-proxy-protocol", @@ -407,7 +407,7 @@ dependencies = [ [[package]] name = "az-mcp-proxy" -version = "0.8.8" +version = "0.8.9" dependencies = [ "az-core", ] @@ -498,7 +498,7 @@ dependencies = [ [[package]] name = "blitz-bench" -version = "0.8.8" +version = "0.8.9" dependencies = [ "blitz-control-protocol", "endpoint-libs", @@ -8978,7 +8978,7 @@ dependencies = [ [[package]] name = "wt-migrate" -version = "0.8.8" +version = "0.8.9" dependencies = [ "chrono", "derive_more 2.1.1", diff --git a/Cargo.toml b/Cargo.toml index 1e958aec..443d5097 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "0.8.8" +version = "0.8.9" edition = "2024" publish = false diff --git a/crates/agency-tools/src/ps_usage.rs b/crates/agency-tools/src/ps_usage.rs index 585ad006..cb922dbc 100644 --- a/crates/agency-tools/src/ps_usage.rs +++ b/crates/agency-tools/src/ps_usage.rs @@ -62,6 +62,23 @@ const OUTCOME_APPLIED: &str = "applied"; const OUTCOME_REFUSED: &str = "refused"; const OUTCOME_OBSERVED: &str = "observed"; +/// The stage carrying a directive's terminal result. +/// +/// One directive writes several rows: `parsed` when it was recognised, then +/// intermediate stages such as `queued`, then this one. Counting outcomes on +/// "any row that is not `parsed`" tallies the same directive more than once, +/// which is how the outcome total came to be an exact multiple of the event +/// total. +const STAGE_COMPLETED: &str = "completed"; + +/// The unit every count in this report is expressed in. +/// +/// One parsed directive is one event. The surface table, the verb table, the +/// per-day table and the outcome table all count that same unit, so their +/// totals are comparable and their sums agree. A row at any other stage is +/// bookkeeping about an event already counted, never a new one. +const EVENT_STAGE: &str = STAGE_PARSED; + /// Which declared surface a verb belongs to. /// /// Derived from the verb rather than stored, because the verb is what the app @@ -274,9 +291,11 @@ pub fn build(rows: &[StudyEventRow], start: &str, end: &str) -> eyre::Result eyre::Result eyre::Result = verbs .into_iter() @@ -463,10 +490,7 @@ pub fn render(report: &Report) -> String { report.outcomes.failed )); for entry in &report.outcomes.failed_by_code { - out.push_str(&format!( - " {:<24} {:>6}\n", - entry.code, entry.events - )); + out.push_str(&format!(" {:<24} {:>6}\n", entry.code, entry.events)); } out.push_str(&format!( " without an outcome {:>6}{}\n\n", @@ -549,25 +573,193 @@ mod tests { fn fixture() -> Vec { vec![ // Turn 1: person wrote a directive; agent emitted two. - row("2026-08-10T09:00:00Z", "study-a", "t1", "", PATHWAY_TURN, OPERATION_TURN, "submitted", OUTCOME_OBSERVED, "", r#"{"userAuthoredPs":true}"#), - row("2026-08-10T09:00:01Z", "study-a", "t1", "i1", PATHWAY_DIRECTIVE, "items.add", STAGE_PARSED, OUTCOME_OBSERVED, "", "{}"), - row("2026-08-10T09:00:02Z", "study-a", "t1", "i1", PATHWAY_DIRECTIVE, "items.add", "completed", OUTCOME_APPLIED, "", "{}"), - row("2026-08-10T09:00:03Z", "study-a", "t1", "i2", PATHWAY_DIRECTIVE, "ask", STAGE_PARSED, OUTCOME_OBSERVED, "", "{}"), - row("2026-08-10T09:00:04Z", "study-a", "t1", "i2", PATHWAY_DIRECTIVE, "ask", "completed", OUTCOME_APPLIED, "", "{}"), + row( + "2026-08-10T09:00:00Z", + "study-a", + "t1", + "", + PATHWAY_TURN, + OPERATION_TURN, + "submitted", + OUTCOME_OBSERVED, + "", + r#"{"userAuthoredPs":true}"#, + ), + row( + "2026-08-10T09:00:01Z", + "study-a", + "t1", + "i1", + PATHWAY_DIRECTIVE, + "items.add", + STAGE_PARSED, + OUTCOME_OBSERVED, + "", + "{}", + ), + row( + "2026-08-10T09:00:02Z", + "study-a", + "t1", + "i1", + PATHWAY_DIRECTIVE, + "items.add", + "completed", + OUTCOME_APPLIED, + "", + "{}", + ), + row( + "2026-08-10T09:00:03Z", + "study-a", + "t1", + "i2", + PATHWAY_DIRECTIVE, + "ask", + STAGE_PARSED, + OUTCOME_OBSERVED, + "", + "{}", + ), + row( + "2026-08-10T09:00:04Z", + "study-a", + "t1", + "i2", + PATHWAY_DIRECTIVE, + "ask", + "completed", + OUTCOME_APPLIED, + "", + "{}", + ), // Turn 2: no directive from the person, one from the agent that failed. - row("2026-08-10T10:00:00Z", "study-a", "t2", "", PATHWAY_TURN, OPERATION_TURN, "submitted", OUTCOME_OBSERVED, "", r#"{"userAuthoredPs":false}"#), - row("2026-08-10T10:00:01Z", "study-a", "t2", "i3", PATHWAY_DIRECTIVE, "pr.link", STAGE_PARSED, OUTCOME_OBSERVED, "", "{}"), - row("2026-08-10T10:00:02Z", "study-a", "t2", "i3", PATHWAY_DIRECTIVE, "pr.link", "completed", OUTCOME_REFUSED, "unknown_item", "{}"), + row( + "2026-08-10T10:00:00Z", + "study-a", + "t2", + "", + PATHWAY_TURN, + OPERATION_TURN, + "submitted", + OUTCOME_OBSERVED, + "", + r#"{"userAuthoredPs":false}"#, + ), + row( + "2026-08-10T10:00:01Z", + "study-a", + "t2", + "i3", + PATHWAY_DIRECTIVE, + "pr.link", + STAGE_PARSED, + OUTCOME_OBSERVED, + "", + "{}", + ), + // The intermediate stage a real directive also writes. Counting it + // as an event, or as an outcome, is what inflated both totals. + row( + "2026-08-10T10:00:01Z", + "study-a", + "t2", + "i3", + PATHWAY_DIRECTIVE, + "pr.link", + "queued", + OUTCOME_OBSERVED, + "", + "{}", + ), + row( + "2026-08-10T10:00:02Z", + "study-a", + "t2", + "i3", + PATHWAY_DIRECTIVE, + "pr.link", + "completed", + OUTCOME_REFUSED, + "unknown_item", + "{}", + ), // Turn 3, next day, different session: no directive either way. - row("2026-08-11T08:00:00Z", "study-b", "t3", "", PATHWAY_TURN, OPERATION_TURN, "submitted", OUTCOME_OBSERVED, "", r#"{"userAuthoredPs":false}"#), + row( + "2026-08-11T08:00:00Z", + "study-b", + "t3", + "", + PATHWAY_TURN, + OPERATION_TURN, + "submitted", + OUTCOME_OBSERVED, + "", + r#"{"userAuthoredPs":false}"#, + ), // Turn 4: a parse with no terminal record, which must surface. - row("2026-08-11T09:00:00Z", "study-b", "t4", "", PATHWAY_TURN, OPERATION_TURN, "submitted", OUTCOME_OBSERVED, "", r#"{"userAuthoredPs":false}"#), - row("2026-08-11T09:00:01Z", "study-b", "t4", "i4", PATHWAY_DIRECTIVE, "items.state", STAGE_PARSED, OUTCOME_OBSERVED, "", "{}"), + row( + "2026-08-11T09:00:00Z", + "study-b", + "t4", + "", + PATHWAY_TURN, + OPERATION_TURN, + "submitted", + OUTCOME_OBSERVED, + "", + r#"{"userAuthoredPs":false}"#, + ), + row( + "2026-08-11T09:00:01Z", + "study-b", + "t4", + "i4", + PATHWAY_DIRECTIVE, + "items.state", + STAGE_PARSED, + OUTCOME_OBSERVED, + "", + "{}", + ), // Exactly at the end of the window: included. - row("2026-08-11T23:59:59Z", "study-b", "t5", "i5", PATHWAY_DIRECTIVE, "items.state", STAGE_PARSED, OUTCOME_OBSERVED, "", "{}"), - row("2026-08-11T23:59:59Z", "study-b", "t5", "i5", PATHWAY_DIRECTIVE, "items.state", "completed", OUTCOME_APPLIED, "", "{}"), + row( + "2026-08-11T23:59:59Z", + "study-b", + "t5", + "i5", + PATHWAY_DIRECTIVE, + "items.state", + STAGE_PARSED, + OUTCOME_OBSERVED, + "", + "{}", + ), + row( + "2026-08-11T23:59:59Z", + "study-b", + "t5", + "i5", + PATHWAY_DIRECTIVE, + "items.state", + "completed", + OUTCOME_APPLIED, + "", + "{}", + ), // One second past the end: excluded. - row("2026-08-12T00:00:00Z", "study-c", "t6", "i6", PATHWAY_DIRECTIVE, "items.add", STAGE_PARSED, OUTCOME_OBSERVED, "", "{}"), + row( + "2026-08-12T00:00:00Z", + "study-c", + "t6", + "i6", + PATHWAY_DIRECTIVE, + "items.add", + STAGE_PARSED, + OUTCOME_OBSERVED, + "", + "{}", + ), ] } @@ -607,6 +799,40 @@ mod tests { assert_eq!(states, 2, "the row exactly at the end is included"); } + /// Every table counts the same unit, so every total is the same number. + /// + /// This is the assertion that would have caught the outcome total coming + /// back at twice the event total and the per-day total at three times it: + /// a directive writes a `parsed`, a `queued` and a `completed` row, and + /// tables that disagreed about which of those is an event disagreed about + /// how many there were. + #[test] + fn every_table_totals_the_same_number_of_events() { + let report = build(&fixture(), START, END).expect("fixture builds"); + let surfaces: usize = report.surfaces.iter().map(|entry| entry.events).sum(); + let verbs: usize = report.verbs.iter().map(|entry| entry.events).sum(); + let per_day: usize = report + .sustained + .events_per_day + .iter() + .map(|entry| entry.events) + .sum(); + + assert_eq!(surfaces, verbs, "surface and verb tables disagree"); + assert_eq!(per_day, verbs, "per-day table counts a different unit"); + + // Outcomes are per terminal record, which is at most one per event. + // Equal would be wrong to assert: a directive that never completed has + // no outcome, and that gap is reported rather than hidden. + let outcomes = + report.outcomes.honored + report.outcomes.normalized + report.outcomes.failed; + assert_eq!( + outcomes + report.outcomes.events_without_outcome, + verbs, + "outcomes plus unresolved events must account for every event" + ); + } + #[test] fn surfaces_split_by_verb_and_share_totals_to_a_hundred() { let report = build(&fixture(), START, END).expect("fixture builds"); @@ -644,7 +870,10 @@ mod tests { fn outcomes_split_by_bucket_and_missing_terminals_are_reported() { let report = build(&fixture(), START, END).expect("fixture builds"); assert_eq!(report.outcomes.honored, 3); - assert_eq!(report.outcomes.normalized, 0, "the app has no normalized state"); + assert_eq!( + report.outcomes.normalized, 0, + "the app has no normalized state" + ); assert_eq!(report.outcomes.failed, 1); assert_eq!(report.outcomes.failed_by_code.len(), 1); assert_eq!(report.outcomes.failed_by_code[0].code, "unknown_item"); @@ -660,7 +889,10 @@ mod tests { assert_eq!(report.sustained.distinct_sessions, 2); assert_eq!(report.sustained.events_per_day.len(), 2); assert_eq!(report.sustained.events_per_day[0].day, "2026-08-10"); - assert_eq!(report.sustained.events_per_day[0].events, 6); + // Three events on that day: items.add, ask and pr.link. The `queued` + // and `completed` rows belong to those same three and are not events + // of their own. + assert_eq!(report.sustained.events_per_day[0].events, 3); } #[test]