From 48cb4c5c980635aa2fbff790b04fcf2754e34743 Mon Sep 17 00:00:00 2001 From: Bradley Hilton Date: Fri, 25 Sep 2026 15:20:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(outcome):=20atomic=20outcome=20generate=20?= =?UTF-8?q?=E2=80=94=20roll=20N=20views=20into=20one=20rollup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `triage review` answers "is *this* view ready to promote?" for one source and one target. An outcome answers the question above that: given N views, what would promoting all of them at once cost, touch, and collide on? atomic outcome generate atomic outcome generate feature-login feature-payments --into dev --json Defaults match the triage fix: sources default to the current view, `--into` to its parent, so the common gesture needs no arguments. Pinned to a urn:atomic:outcome: reference over the source/target Merkle states and the candidate change set, order-insensitive — the same JCS+BLAKE3 scheme as urn:atomic:triage, so the two reports can be diffed against each other. The N-view union was cheap: every filter *consumer* (ViewGraph, RetrieveOptions, MaterializeOptions.change_filter) already takes a HashSet and is indifferent to which views produced it. Only the constructors were single-view, so `visible_change_ids_for_views` closes that gap and the rest is folds over already-loaded data. Two places are deliberately conservative, because a confident wrong number is worse than no number: - Cost is per-change provenance, never session attestations. An attestation's cost_usd covers a whole session, so across N views it would either double-count or need a split with no principled basis. When nothing carries provenance the report says `cost unknown` rather than printing $0.00, which would read as "this work was free". - Mergeability is a three-state answer, not a boolean. SemanticMergeEngine is two-way only, so a conflict spanning 3+ sources is *unadjudicable* — it degrades to NoCrdtData internally and surfaces identically to a real conflict. Those report NOT COMPUTABLE, never NEEDS HUMAN: a genuinely unverifiable stacked PR must not be indistinguishable from a pass. The probe is read-only — it materializes the union into the discard-everything Sink working copy, so it leaves the repository byte-for-byte unchanged. Per-view costs form a *partition* of the union (a change present in several sources is attributed to exactly one, deterministically), so the per-view figures sum to the total instead of double-counting it. 19 new tests. Note: an integration test can realistically only observe the CLEAN branch — the merge engine resolves nearly everything we could construct by hand — so the tier decision is a pure `classify_conflicts` with direct unit coverage of the 1/2/3/4-way cases. --- atomic-canonical/src/lib.rs | 2 + atomic-canonical/src/outcome_ref.rs | 132 +++ atomic-cli/src/commands/mod.rs | 2 + atomic-cli/src/commands/outcome/mod.rs | 233 +++++ atomic-cli/src/commands/outcome/output.rs | 282 +++++++ atomic-cli/src/main.rs | 10 + atomic-repository/src/repository/filter.rs | 26 + atomic-repository/src/repository/mod.rs | 7 +- atomic-repository/src/repository/outcome.rs | 886 ++++++++++++++++++++ atomic-repository/src/repository/triage.rs | 5 +- 10 files changed, 1583 insertions(+), 2 deletions(-) create mode 100644 atomic-canonical/src/outcome_ref.rs create mode 100644 atomic-cli/src/commands/outcome/mod.rs create mode 100644 atomic-cli/src/commands/outcome/output.rs create mode 100644 atomic-repository/src/repository/outcome.rs diff --git a/atomic-canonical/src/lib.rs b/atomic-canonical/src/lib.rs index 8b862f9a..d882c3b0 100644 --- a/atomic-canonical/src/lib.rs +++ b/atomic-canonical/src/lib.rs @@ -39,6 +39,7 @@ pub mod jcs; pub mod lift; pub mod memory; pub mod node; +pub mod outcome_ref; pub mod proof; pub mod prov; pub mod render; @@ -52,6 +53,7 @@ pub use node::{ intent_substance_hash, AcceptanceCriterion, CanonicalNode, Constraint, Proof, Ref, ScopeItem, Task, }; +pub use outcome_ref::{outcome_reference, verify_outcome_reference, OutcomePins}; pub use prov::{attest_prov, project, verify_prov, ProvActivityInput}; pub use render::{render, render_memory, Target}; pub use triage_ref::{triage_reference, verify_triage_reference, TriagePins}; diff --git a/atomic-canonical/src/outcome_ref.rs b/atomic-canonical/src/outcome_ref.rs new file mode 100644 index 00000000..2ea1f257 --- /dev/null +++ b/atomic-canonical/src/outcome_ref.rs @@ -0,0 +1,132 @@ +//! The `urn:atomic:outcome:` reference scheme. +//! +//! An outcome reference is a content address for an *outcome observation*: the +//! pins that fix a rollup of N views against a target — the views, the Merkle +//! state each was observed at, and the candidate change-set they contribute. +//! Two outcomes over exactly the same facts get the same reference; any change +//! to a pin yields a different one. +//! +//! This is the same scheme as [`crate::triage_ref`], and deliberately so: a +//! triage reference answers "was *this* change-set reviewed?", an outcome +//! reference answers "what did *this* body of work cost and produce?". Making +//! them the same shape means the two reports can be pinned, stored, and later +//! diffed against each other with one mechanism. +//! +//! Deliberately **string-based** (base32/hex strings, not typed hashes) so it +//! needs no `atomic-core` dependency — the caller supplies already-encoded +//! identifiers. Hashing goes through the one [`crate::jcs`] + BLAKE3 path the +//! rest of the crate uses, so the reference can never drift from a content hash. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::jcs; + +/// The pinned facts an outcome reference addresses. +/// +/// Field ordering is irrelevant to the hash: the two [`BTreeMap`]s +/// canonicalize, and `candidate_changes` is sorted before hashing, so only the +/// *set* of views and changes matters — not the order the user listed them in. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OutcomePins { + /// The source views being rolled up, in the order the user named them. + /// Sorted before hashing, so `a b` and `b a` pin identically. + pub sources: Vec, + /// The target view the sources would be promoted into. + pub target: String, + /// View name → base32 of that view's Merkle at observation time — the + /// materialized state the rollup is a fact about. + pub source_merkle: BTreeMap, + /// Base32 Merkle of the target view at observation time. + pub target_merkle: String, + /// Base32 change hashes in the union of the sources' deltas. Order is not + /// significant (sorted before hashing). + pub candidate_changes: Vec, +} + +/// The `urn:atomic:outcome:` reference for a set of pins. +/// +/// `candidate_changes` and `sources` are sorted before hashing so an outcome +/// over the same set of views and changes in any order gets the same +/// reference. +pub fn outcome_reference(pins: &OutcomePins) -> String { + let mut canonical_pins = pins.clone(); + canonical_pins.sources.sort(); + canonical_pins.candidate_changes.sort(); + let value = + serde_json::to_value(&canonical_pins).expect("OutcomePins serialization is infallible"); + let canonical = jcs::canonicalize(&value); + let digest = blake3::hash(canonical.as_bytes()); + format!("urn:atomic:outcome:{}", digest.to_hex()) +} + +/// Recompute the reference for `pins` and compare it to `reference`. True iff +/// the pins hash to exactly this reference (order-insensitive for sources and +/// candidate changes). +pub fn verify_outcome_reference(pins: &OutcomePins, reference: &str) -> bool { + outcome_reference(pins) == reference +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pins() -> OutcomePins { + let mut merkle = BTreeMap::new(); + merkle.insert("feature-a".to_string(), "MERCLEA".to_string()); + merkle.insert("feature-b".to_string(), "MERCLEB".to_string()); + OutcomePins { + sources: vec!["feature-a".to_string(), "feature-b".to_string()], + target: "dev".to_string(), + source_merkle: merkle, + target_merkle: "MERKLEDEV".to_string(), + candidate_changes: vec!["HASHB".to_string(), "HASHA".to_string()], + } + } + + #[test] + fn reference_is_stable_across_input_order() { + let mut reordered = pins(); + reordered.sources.reverse(); + reordered.candidate_changes.reverse(); + assert_eq!(outcome_reference(&pins()), outcome_reference(&reordered)); + } + + #[test] + fn reference_round_trips() { + assert!(verify_outcome_reference( + &pins(), + &outcome_reference(&pins()) + )); + } + + #[test] + fn changing_a_pin_changes_the_reference() { + let base = outcome_reference(&pins()); + + let mut other_target = pins(); + other_target.target = "release".to_string(); + assert_ne!(base, outcome_reference(&other_target)); + + let mut other_merkle = pins(); + other_merkle.target_merkle = "MERKLE2".to_string(); + assert_ne!(base, outcome_reference(&other_merkle)); + + let mut added_view = pins(); + added_view.sources.push("feature-c".to_string()); + assert_ne!(base, outcome_reference(&added_view)); + + let mut added_change = pins(); + added_change.candidate_changes.push("HASHC".to_string()); + assert_ne!(base, outcome_reference(&added_change)); + } + + #[test] + fn reference_has_the_documented_shape() { + let reference = outcome_reference(&pins()); + assert!(reference.starts_with("urn:atomic:outcome:")); + // blake3 hex is 64 chars. + assert_eq!(reference.len(), "urn:atomic:outcome:".len() + 64); + } +} diff --git a/atomic-cli/src/commands/mod.rs b/atomic-cli/src/commands/mod.rs index d5ff1859..dd47f668 100644 --- a/atomic-cli/src/commands/mod.rs +++ b/atomic-cli/src/commands/mod.rs @@ -80,6 +80,7 @@ pub mod intent; pub mod log; pub mod memory; pub mod mv; +pub mod outcome; pub mod provenance; pub mod record; pub mod remove; @@ -148,6 +149,7 @@ pub use intent::Intent; pub use log::Log; pub use memory::Memory; pub use mv::Move; +pub use outcome::Outcome; pub use project::ProjectCmd; pub use provenance::Provenance; pub use pull::Pull; diff --git a/atomic-cli/src/commands/outcome/mod.rs b/atomic-cli/src/commands/outcome/mod.rs new file mode 100644 index 00000000..a019ad4e --- /dev/null +++ b/atomic-cli/src/commands/outcome/mod.rs @@ -0,0 +1,233 @@ +//! `atomic outcome` — collapse a set of views into one rollup. +//! +//! Where `atomic triage review` answers "is *this* view ready to promote?" for +//! one source and one target, an outcome answers the question above that: +//! given **N** views, what would promoting all of them at once cost, touch, +//! and collide on? +//! +//! The report is a read-only projection. It creates no records, moves no +//! changes, and writes nothing to disk. + +use clap::{Parser, Subcommand}; +use clap_complete::engine::ArgValueCompleter; + +use atomic_repository::Repository; + +use crate::commands::complete::complete_view_names; +use crate::commands::{find_repository_root, Command}; +use crate::error::{CliError, CliResult}; + +pub mod output; + +/// Roll up a set of views into a single outcome. +#[derive(Debug, Parser)] +#[command(after_help = "\ + defaults to the current view; --into defaults to that view's parent. Name +several views to ask what promoting all of them at once would cost. + +Examples: + # What would promoting the current view cost? + atomic outcome generate + atomic outcome generate --json + + # A stacked promotion: several views at once + atomic outcome generate feature-login feature-payments --into dev + atomic outcome generate feature-login feature-payments --into dev --json")] +pub struct Outcome { + #[command(subcommand)] + pub command: OutcomeCommands, +} + +impl Command for Outcome { + fn run(&self) -> CliResult<()> { + match &self.command { + OutcomeCommands::Generate(cmd) => cmd.run(), + } + } +} + +/// Subcommands for `atomic outcome`. +#[derive(Subcommand, Debug)] +pub enum OutcomeCommands { + /// Collapse N views into one rollup: cost, footprint, and mergeability. + /// + /// See this subcommand's examples. + Generate(OutcomeGenerate), +} + +/// Generate the outcome for a set of views against a target. +#[derive(Debug, Parser)] +#[command(after_help = "\ + defaults to the current view; --into defaults to that view's parent. Name +several views to ask what promoting all of them at once would cost. + +Examples: + # What would promoting the current view cost? + atomic outcome generate + atomic outcome generate --json + + # A stacked promotion: several views at once + atomic outcome generate feature-login feature-payments --into dev + atomic outcome generate feature-login feature-payments --into dev --json")] +pub struct OutcomeGenerate { + /// The source view(s) to roll up. Defaults to the current view. + #[arg( + value_name = "VIEW", + add = ArgValueCompleter::new(complete_view_names) + )] + pub views: Vec, + + /// The target view the sources would be promoted into. Defaults to the + /// first source view's parent. + #[arg( + long, + value_name = "VIEW", + add = ArgValueCompleter::new(complete_view_names) + )] + pub into: Option, + + /// Emit the full outcome as JSON. + #[arg(long)] + pub json: bool, +} + +impl Command for OutcomeGenerate { + fn run(&self) -> CliResult<()> { + let root = find_repository_root()?; + let repo = Repository::open(&root).map_err(CliError::Repository)?; + let (sources, target) = resolve_views(&repo, &self.views, self.into.as_deref())?; + + let outcome = repo + .outcome(&sources, &target) + .map_err(CliError::Repository)?; + + if self.json { + println!( + "{}", + serde_json::to_string_pretty(&outcome).map_err(|e| { + CliError::Internal(anyhow::anyhow!("failed to serialize outcome: {e}")) + })? + ); + return Ok(()); + } + + output::print_outcome(&outcome); + Ok(()) + } +} + +/// Resolve the (sources, target) pair. +/// +/// With no views named, the source is the current view and the target its +/// parent — the same default `atomic triage review` and a bare `atomic insert` +/// use, so the common gesture needs no arguments at all. +fn resolve_views( + repo: &Repository, + views: &[String], + into: Option<&str>, +) -> CliResult<(Vec, String)> { + let sources: Vec = if views.is_empty() { + vec![repo.current_view().to_string()] + } else { + for name in views { + if !repo.view_exists(name).map_err(CliError::Repository)? { + return Err(unknown_view(repo, name, "")); + } + } + views.to_vec() + }; + + let target = match into { + Some(name) => { + if !repo.view_exists(name).map_err(CliError::Repository)? { + return Err(unknown_view(repo, name, "--into")); + } + name.to_string() + } + None => match repo + .parent_change_count(&sources[0]) + .map_err(CliError::Repository)? + { + Some((parent, _)) => parent, + None => { + return Err(CliError::InvalidArgument { + message: format!( + "'{}' is a root view — it has no parent to promote into.\n \ + Pass --into to choose a target (see 'atomic view list').", + sources[0] + ), + }) + } + }, + }; + + Ok((sources, target)) +} + +/// The error for a view name that does not resolve. +/// +/// The usual cause is typing the placeholder from the help text instead of a +/// real view name, so the message says the argument is optional and names the +/// current view as the value to simply drop in — or the value to leave off +/// entirely. +fn unknown_view(repo: &Repository, name: &str, arg: &str) -> CliError { + CliError::InvalidArgument { + message: format!( + "No view named '{name}' — {arg} takes a real view name, not a placeholder.\n \ + Current view is '{}'; omit {arg} to roll that up instead, or run \ + 'atomic view list' to see all views.", + repo.current_view() + ), + } +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + + use super::*; + + fn parse(args: &[&str]) -> OutcomeGenerate { + OutcomeGenerate::try_parse_from(std::iter::once("generate").chain(args.iter().copied())) + .unwrap() + } + + #[test] + fn source_views_are_a_optional_list() { + assert!(parse(&[]).views.is_empty()); + assert_eq!(parse(&["a"]).views, vec!["a".to_string()]); + assert_eq!( + parse(&["a", "b", "c"]).views, + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); + assert_eq!(parse(&["--into", "dev"]).into.as_deref(), Some("dev")); + } + + #[test] + fn usage_advertises_both_arguments_as_optional() { + for cmd in [OutcomeGenerate::command()] { + for id in ["views", "into"] { + let arg = cmd + .get_arguments() + .find(|a| a.get_id() == id) + .unwrap_or_else(|| panic!("missing argument `{id}`")); + assert!(!arg.is_required_set(), "`{id}` is still required"); + } + } + let usage = OutcomeGenerate::command().render_usage().to_string(); + assert!(usage.contains("[VIEW]"), "usage: {usage}"); + assert!(!usage.contains("[FEATURE]"), "usage: {usage}"); + } + + #[test] + fn help_carries_the_stacked_examples() { + // `apply_agent_help` drops `long_about` but keeps `after_help`, so the + // examples have to live there to be discoverable. + let help = OutcomeGenerate::command().render_long_help().to_string(); + assert!(help.contains("Examples:"), "help missing examples:\n{help}"); + assert!( + help.contains("feature-login feature-payments --into dev"), + "help missing the stacked example:\n{help}" + ); + } +} diff --git a/atomic-cli/src/commands/outcome/output.rs b/atomic-cli/src/commands/outcome/output.rs new file mode 100644 index 00000000..95cf5d1e --- /dev/null +++ b/atomic-cli/src/commands/outcome/output.rs @@ -0,0 +1,282 @@ +//! The bounded CLI skin for an [`Outcome`]. +//! +//! Bounded on purpose: an outcome over a large body of work can touch thousands +//! of files, so paths and model rows are truncated with an explicit tail count +//! rather than dumped. The `--json` form carries the full set. + +use atomic_repository::{ + Mergeability, ModelSpend, Outcome, OutcomeCost, OutcomeFootprint, ViewOutcome, +}; + +use crate::output::{emphasis, hint, info}; + +/// How many model rows to show before collapsing into a tail count. +const MAX_MODELS_SHOWN: usize = 8; + +/// How many file paths to show before collapsing into a count. +const MAX_PATHS_SHOWN: usize = 12; + +/// How many uncovered-file names to name before counting only. +const MAX_UNCOVERED_SHOWN: usize = 3; + +/// Render an outcome to stdout as the bounded human report. +pub fn print_outcome(outcome: &Outcome) { + let label = mergeability_label(&outcome.mergeability); + println!( + "{} {} \u{2192} {} {} ({} change{}, {} file{})", + emphasis("outcome"), + outcome.sources.join("+"), + outcome.target, + label, + outcome.footprint.changes, + plural(outcome.footprint.changes), + outcome.footprint.files, + plural(outcome.footprint.files), + ); + + if !outcome.sources.is_empty() && outcome.sources.len() > 1 { + println!(" {}", hint("stacked — all sources roll up as one unit")); + } + + print_cost(&outcome.cost); + print_mergeability(&outcome.mergeability); + print_footprint(&outcome.footprint); + print_views(&outcome.views); + + println!("\n {}", hint(&outcome.reference)); +} + +/// The cost side. Prints "unknown" rather than `$0.00` when no change carried +/// provenance — a zero that means "not recorded" must not read as "free". +fn print_cost(cost: &OutcomeCost) { + if !cost.cost_known { + if cost.unattributed_changes > 0 { + println!( + " cost {}", + hint(&format!( + "unknown \u{2014} none of {} change(s) carry provenance", + cost.unattributed_changes + )) + ); + } else { + println!( + " cost {}", + hint("unknown \u{2014} no changes in this set") + ); + } + } else { + println!( + " cost {} {}", + money(cost.micro_usd), + hint(&format!( + "{} tokens \u{00b7} {} in / {} out", + cost.total_tokens, cost.input_tokens, cost.output_tokens + )) + ); + for model in cost.by_model.iter().take(MAX_MODELS_SHOWN) { + print_model(model); + } + if cost.by_model.len() > MAX_MODELS_SHOWN { + println!( + " {}", + hint(&format!( + "\u{2026} {} more model(s)", + cost.by_model.len() - MAX_MODELS_SHOWN + )) + ); + } + } + if cost.unreadable_changes > 0 { + println!( + " {}", + crate::output::warning(&format!( + "{} change(s) unreadable \u{2014} excluded from these totals", + cost.unreadable_changes + )) + ); + } +} + +fn print_model(model: &ModelSpend) { + println!( + " {} {} {}", + model.model, + money(model.micro_usd), + hint(&format!( + "{} tokens \u{00b7} {} change{}", + model.total_tokens, + model.changes, + plural(model.changes) + )) + ); +} + +/// The headline answer: can this be promoted as one unit? +fn print_mergeability(mergeability: &Mergeability) { + let conflicts = match mergeability { + Mergeability::Clean => { + println!(" merge {}", info("CLEAN \u{2014} no residual conflict")); + return; + } + Mergeability::NeedsHuman { conflicts } => { + println!( + " merge {}", + crate::output::error(&format!( + "NEEDS HUMAN \u{2014} {} conflict{}", + conflicts.len(), + plural(conflicts.len()) + )) + ); + conflicts + } + Mergeability::NotComputable { reason, conflicts } => { + println!( + " merge {}", + crate::output::warning(&format!( + "NOT COMPUTABLE \u{2014} {} conflict{}", + conflicts.len(), + plural(conflicts.len()) + )) + ); + println!(" {}", hint(reason)); + conflicts + } + }; + + for conflict in conflicts { + let over = if conflict.concurrent_sources > 2 { + format!( + " ({}-way \u{2014} beyond 2-side merge)", + conflict.concurrent_sources + ) + } else { + String::new() + }; + println!( + " {} {} {}{}", + conflict.path, + hint(&format!("[{}]", conflict.kind)), + conflict.views.join("+"), + over + ); + } +} + +/// The shape of the work. +fn print_footprint(footprint: &OutcomeFootprint) { + if footprint.baggage > 0 { + let named: Vec = footprint + .baggage_files + .iter() + .take(MAX_UNCOVERED_SHOWN) + .map(|f| f.trim_start_matches("file:").to_string()) + .collect(); + let more = footprint + .baggage_files + .len() + .saturating_sub(MAX_UNCOVERED_SHOWN); + let tail = if more > 0 { + format!(" (+{more} more)") + } else { + String::new() + }; + println!( + " {} {}", + crate::output::warning(&format!("{} uncovered", footprint.baggage)), + hint(&format!("no intent touches {}", named.join(", ") + &tail)) + ); + } + for path in footprint.paths.iter().take(MAX_PATHS_SHOWN) { + println!(" {}", hint(path)); + } + if footprint.paths.len() > MAX_PATHS_SHOWN { + println!( + " {}", + hint(&format!( + "\u{2026} {} more file{}", + footprint.paths.len() - MAX_PATHS_SHOWN, + plural(footprint.paths.len() - MAX_PATHS_SHOWN) + )) + ); + } +} + +/// The per-source partition of the union. One source needs no breakdown — the +/// totals above already describe it. +fn print_views(views: &[ViewOutcome]) { + if views.len() < 2 { + return; + } + println!("\n{}", emphasis("By view")); + for view in views { + let cost = if view.cost.cost_known { + money(view.cost.micro_usd) + } else { + hint("cost unknown").to_string() + }; + println!( + " {} {} {} change{} \u{00b7} {} file{} \u{00b7} {}", + view.view, + hint(&format!("[{}]", view.scope)), + view.changes, + plural(view.changes), + view.files, + plural(view.files), + cost + ); + } +} + +fn mergeability_label(mergeability: &Mergeability) -> String { + match mergeability { + Mergeability::Clean => info("CLEAN").to_string(), + Mergeability::NeedsHuman { conflicts } => { + crate::output::error(format!("{} CONFLICT(S)", conflicts.len())).to_string() + } + Mergeability::NotComputable { conflicts, .. } => { + crate::output::warning(&format!("{} CONFLICT(S), NOT COMPUTABLE", conflicts.len())) + .to_string() + } + } +} + +/// A micro-USD integer rendered as dollars. Sub-cent spend keeps enough digits +/// that a real turn is never rounded away to `$0.00`. +fn money(micro_usd: u64) -> String { + match micro_usd { + 0 => "$0.00".to_string(), + 1..=9_999 => format!("${:.4}", micro_usd as f64 / 1_000_000.0), + _ => format!("${:.2}", micro_usd as f64 / 1_000_000.0), + } +} + +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sub_cent_spend_is_not_rounded_away() { + // A single cheap turn must not render as "$0.00", which would read as + // "this work was free". + assert_eq!(money(1), "$0.0000"); + assert_eq!(money(1_200), "$0.0012"); + assert_eq!(money(9_999), "$0.0100"); + assert_eq!(money(0), "$0.00"); + assert_eq!(money(1_500_000), "$1.50"); + } + + #[test] + fn plurals_are_correct() { + assert_eq!(plural(0), "s"); + assert_eq!(plural(1), ""); + assert_eq!(plural(2), "s"); + } +} diff --git a/atomic-cli/src/main.rs b/atomic-cli/src/main.rs index 1cd1e275..285beb48 100644 --- a/atomic-cli/src/main.rs +++ b/atomic-cli/src/main.rs @@ -73,6 +73,7 @@ use commands::{ Log, Memory, Move, + Outcome, ProjectCmd, Provenance, Pull, @@ -865,6 +866,14 @@ enum Commands { /// with no arguments asks whether the current view is ready to promote. Triage(Triage), + /// Collapse N views into one rollup: cost, footprint, and mergeability. + /// + /// Where `triage review` asks "is *this* view ready to promote?", an + /// outcome asks what promoting several at once would cost, touch, and + /// collide on. Both view arguments are optional: the sources default to + /// the current view and the target to its parent. + Outcome(Outcome), + /// Generate a shell completion script. /// /// Emits a static completion script for the given shell. For live @@ -1045,6 +1054,7 @@ fn main() { Commands::Provenance(provenance) => provenance.run(), Commands::Triage(triage) => triage.run(), + Commands::Outcome(outcome) => outcome.run(), }; // Handle errors with user-friendly output diff --git a/atomic-repository/src/repository/filter.rs b/atomic-repository/src/repository/filter.rs index 2ac0e9fe..81732037 100644 --- a/atomic-repository/src/repository/filter.rs +++ b/atomic-repository/src/repository/filter.rs @@ -120,6 +120,32 @@ pub fn collect_visible_change_ids_with_deps( Ok(ids) } +/// Collect the **union** of the effective visible change sets of several views, +/// each with its own dependency closure expanded. +/// +/// This is the N-view analogue of [`collect_visible_change_ids_with_deps`], and +/// it exists because every *filter constructor* in the pristine layer is +/// single-view while everything *below* it is already set-shaped: +/// `ViewGraph`, `RetrieveOptions` and `MaterializeOptions.change_filter` all +/// take a `HashSet` and are wholly indifferent to which views produced +/// it. A set of views therefore needs no support anywhere downstream — only a +/// way to build the set. This function is that way. +/// +/// # Complexity +/// +/// O(C) in the total number of visible changes across `views`, plus one +/// closure expansion per view. +pub fn visible_change_ids_for_views( + txn: &T, + views: &[atomic_core::pristine::ViewState], +) -> Result, RepositoryError> { + let mut ids = HashSet::new(); + for view in views { + ids.extend(collect_visible_change_ids_with_deps(txn, view)?); + } + Ok(ids) +} + /// Fold a view's **effective visible change set** into an order-invariant /// [`SetId`]. /// diff --git a/atomic-repository/src/repository/mod.rs b/atomic-repository/src/repository/mod.rs index 4c98e8fe..62635b1e 100644 --- a/atomic-repository/src/repository/mod.rs +++ b/atomic-repository/src/repository/mod.rs @@ -79,6 +79,7 @@ use crate::RepositoryError; mod deferred_tree; mod filter; mod materialize; +mod outcome; mod sandbox; mod semantic_materialize; mod split; @@ -89,7 +90,7 @@ mod views; // use `use super::*;` continue to resolve them at `crate::repository::…`. pub use filter::{ collect_view_change_ids, collect_visible_change_ids, collect_visible_change_ids_with_deps, - expand_indexed_dependency_closure, view_set_id, + expand_indexed_dependency_closure, view_set_id, visible_change_ids_for_views, }; pub use sandbox::{SealOptions, SealResult, StageOptions, StageResult, SANDBOX_POINTER}; pub use split::{SplitChange, SplitOptions, SplitOutcome}; @@ -126,6 +127,10 @@ mod verify; pub use insert::{ ImportLineIndexSeed, ImportLineIndexSeedLine, ImportWriteOutcome, ImportWriteTimings, }; +pub use outcome::{ + Mergeability, ModelSpend, Outcome, OutcomeConflict, OutcomeCost, OutcomeFootprint, + OutcomeInputs, ViewOutcome, +}; pub use provenance_summary::ProvenanceSummary; pub use semantic_materialize::{CrdtMaterializeOptions, CrdtMaterializeOutcome}; pub use tags::{deserialize_tag, serialize_tag}; diff --git a/atomic-repository/src/repository/outcome.rs b/atomic-repository/src/repository/outcome.rs new file mode 100644 index 00000000..9f4e3a20 --- /dev/null +++ b/atomic-repository/src/repository/outcome.rs @@ -0,0 +1,886 @@ +//! Outcome rollup — "what did this body of work cost and produce?". +//! +//! A [`CandidateSet`](crate::repository::CandidateSet) answers "is *this* view +//! ready to promote?" for exactly one source view against one target. An +//! outcome answers the question above that: given **N** views, what would +//! promoting all of them at once cost, touch, and collide on? +//! +//! # Why this is cheap +//! +//! Everything the graph layer consumes is already a `HashSet` and is +//! indifferent to which views produced it — `ViewGraph`, `RetrieveOptions`, +//! `MaterializeOptions.change_filter`. Only the filter *constructors* are +//! single-view, and [`visible_change_ids_for_views`](crate::repository::filter::visible_change_ids_for_views) +//! closes that gap. So the union of N views is representable everywhere below +//! the repository API today; this module is a set of folds over data that is +//! already loaded, plus one read-only materialization into a discard-everything +//! working copy. +//! +//! # Honesty rules +//! +//! Two places in this module are deliberately conservative, because a confident +//! wrong number is worse than no number: +//! +//! - **Cost is per-change provenance, never session attestations.** An +//! attestation's `cost_usd` covers a whole session, so across N views it +//! would either double-count or have to be split with no principled basis. +//! Per-change [`Provenance`](atomic_core::change::provenance::Provenance) is +//! already attributed. When nothing in the set carries provenance the report +//! says so via [`OutcomeCost::cost_known`] rather than printing `$0.00`, +//! which would read as "this work was free". +//! +//! - **Mergeability is a three-state answer, not a boolean.** The semantic +//! merge engine is two-way only (see `SemanticMergeEngine::try_merge`), +//! so a conflict spanning three or more source views is *unadjudicable* — +//! it degrades to `NoCrdtData` internally and surfaces identically to a real +//! conflict. Reporting those as "needs a human" would be indistinguishable +//! from a pass, which is precisely the unfalsifiable claim this product +//! exists to eliminate. See [`Mergeability`]. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use atomic_canonical::{outcome_reference, OutcomePins}; +use atomic_core::output::repo::FileConflictType; +use atomic_core::pristine::CachedGraphTxn; +use serde::Serialize; + +use super::*; + +/// Token and dollar spend for one model. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ModelSpend { + /// Model name as recorded in provenance. + pub model: String, + /// Candidate changes that used this model. + pub changes: usize, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + pub reasoning_tokens: u64, + /// `input + output` (cache and reasoning are reported separately; they are + /// subsets of the provider's own billing categories, not additive here). + pub total_tokens: u64, + /// Exact cost in millionths of a dollar. + pub micro_usd: u64, + /// The same cost as a float, for display. + pub usd: f64, +} + +/// The cost side of an outcome. +#[derive(Debug, Clone, Default, Serialize)] +pub struct OutcomeCost { + /// Total cost, float view. + pub usd: f64, + /// Total cost, exact integer micro-USD. Authoritative; `usd` is derived. + pub micro_usd: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + pub reasoning_tokens: u64, + pub total_tokens: u64, + /// Spend bucketed by model, ordered by model name. + pub by_model: Vec, + /// Candidate changes carrying at least one provenance record. + pub attributed_changes: usize, + /// Candidate changes with no provenance — human- or system-authored. + pub unattributed_changes: usize, + /// Candidate changes the change store could not load. Excluded from every + /// count and total above, and reported separately so a silent skip cannot + /// quietly distort the spend figure. + pub unreadable_changes: usize, + /// False when *no* candidate change carried provenance — the harness did + /// not record it. `usd == 0` then means "unknown", not "free". + pub cost_known: bool, +} + +impl OutcomeCost { + /// True when the rollup is empty of attributable spend. + pub fn is_empty(&self) -> bool { + self.by_model.is_empty() + } +} + +/// One conflicted file, attributed to the source views that produced the +/// overlapping changes. +#[derive(Debug, Clone, Serialize)] +pub struct OutcomeConflict { + /// Repository-relative path. + pub path: String, + /// Graph conflict kind: `name`, `order`, `cyclic`, `zombie`, or + /// `zombie_file`. + pub kind: String, + /// Distinct source views contributing changes to this file, sorted. + pub views: Vec, + /// How many of those views are *concurrent* with each other. 0 or 1 means + /// the overlap is only with already-present content; 2 is a two-way + /// disagreement the merge engine could adjudicate; **3 or more is beyond + /// the two-way engine's reach** and is reported as not computable. + pub concurrent_sources: usize, +} + +/// Whether the candidate set can be promoted as one unit. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Mergeability { + /// The union materializes with no residual conflict. + Clean, + /// Conflicts found, and every one spans at most two source views — each is + /// a two-way overlap the merge engine could adjudicate, so a human decides. + NeedsHuman { + /// The conflicts, sorted by path. + conflicts: Vec, + }, + /// At least one conflict spans three or more source views. The semantic + /// merge engine is two-way only, so this report **cannot** say whether the + /// set merges cleanly. This is explicitly not a pass. + NotComputable { + /// Why the answer is unavailable, in plain English. + reason: String, + /// Every conflict found, including the adjudicable ones. + conflicts: Vec, + }, +} + +impl Mergeability { + /// True only for [`Mergeability::Clean`]. + pub fn is_clean(&self) -> bool { + matches!(self, Mergeability::Clean) + } +} + +/// The shape of the work: how many changes, how many files, how much of it is +/// unaccounted for. +#[derive(Debug, Clone, Default, Serialize)] +pub struct OutcomeFootprint { + /// Distinct candidate changes across all sources. + pub changes: usize, + /// Distinct files touched across all sources. + pub files: usize, + /// Every path touched, sorted. + pub paths: Vec, + /// Changes in the set that no task in any intent touches — "baggage". + pub baggage: usize, + /// The `file:` node ids of the baggage changes, sorted. + pub baggage_files: Vec, +} + +/// One source view's own contribution to the outcome. +#[derive(Debug, Clone, Serialize)] +pub struct ViewOutcome { + /// View name. + pub view: String, + /// `shared` or `draft`. + pub scope: String, + /// Parent view name, if any. + pub parent: Option, + /// Changes this view contributes to the union that `target` does not have. + pub changes: usize, + /// Distinct files those changes touch. + pub files: usize, + /// Spend attributed to exactly this view's contribution. + /// + /// The per-view contributions form a **partition** of the union: a change + /// present in several sources is attributed to exactly one of them + /// (deterministically, the lexicographically first view name) so the + /// per-view figures sum to the total rather than double-counting it. + pub cost: OutcomeCost, +} + +/// The pinned facts an outcome is a fact about. +#[derive(Debug, Clone, Serialize)] +pub struct OutcomeInputs { + /// Base32 Merkle of the target view. + pub target_merkle: String, + /// View name → base32 Merkle, for every source. + pub source_merkle: BTreeMap, + /// Base32 change hashes in the union of the sources' deltas, sorted. + pub candidate_changes: Vec, +} + +/// The collapsed rollup of N views against a target. +#[derive(Debug, Clone, Serialize)] +pub struct Outcome { + /// The source views rolled up, in the order the caller named them. + pub sources: Vec, + /// The view they would be promoted into. + pub target: String, + /// The pinned inputs this outcome is a fact about. + pub inputs: OutcomeInputs, + /// `urn:atomic:outcome:` over `inputs`. + pub reference: String, + /// Total spend across the union. + pub cost: OutcomeCost, + /// The shape of the work. + pub footprint: OutcomeFootprint, + /// Whether it can be promoted as one unit. + pub mergeability: Mergeability, + /// Per-source breakdown. + pub views: Vec, +} + +impl Repository { + /// Roll up `sources` into a single outcome relative to `target`. + /// + /// `sources` may be one view or many; the result is the same shape either + /// way, so a caller can promote a set without special-casing the common + /// single-view case. Order does not matter — the reference is + /// order-insensitive. + /// + /// # Errors + /// + /// Returns [`RepositoryError::ViewNotFound`] if any named view does not + /// exist, or [`RepositoryError::Database`] on a pristine access failure. + pub fn outcome(&self, sources: &[String], target: &str) -> Result { + if sources.is_empty() { + return Err(RepositoryError::InvalidOperation { + message: "outcome requires at least one source view".to_string(), + }); + } + + let txn = self + .pristine + .read_txn() + .map_err(|e| RepositoryError::Database(e.to_string()))?; + + let resolve = |name: &str| -> Result { + txn.get_view(name) + .map_err(|e| RepositoryError::Database(e.to_string()))? + .ok_or_else(|| RepositoryError::ViewNotFound { + name: name.to_string(), + }) + }; + + let target_view = resolve(target)?; + let target_visible = collect_visible_change_ids_with_deps(&txn, &target_view)?; + + // Per-source deltas, and the union of them. + let mut states = Vec::with_capacity(sources.len()); + let mut per_view_visible: HashMap> = HashMap::new(); + let mut union: HashSet = HashSet::new(); + for name in sources { + let state = resolve(name)?; + let visible = collect_visible_change_ids_with_deps(&txn, &state)?; + let delta: HashSet = visible + .iter() + .copied() + .filter(|id| !target_visible.contains(id)) + .collect(); + union.extend(delta.iter().copied()); + per_view_visible.insert(name.clone(), delta); + states.push(state); + } + + // External hashes for the union, in deterministic order. + let mut candidate: Vec = Vec::new(); + for id in &union { + if let Some(hash) = txn + .get_external(*id) + .map_err(|e| RepositoryError::Database(e.to_string()))? + { + candidate.push(hash); + } + } + candidate.sort_by_key(|h| h.to_base32()); + + // ── Cost ──────────────────────────────────────────────────────────── + // A change present in several sources is attributed to exactly one so + // the per-view figures partition the union instead of double-counting. + let owner: HashMap = { + let mut names: Vec<&String> = sources.iter().collect(); + names.sort(); + let mut map = HashMap::new(); + for name in names { + for hash in self.hashes_of(&txn, &per_view_visible[name])? { + map.entry(hash).or_insert_with(|| name.clone()); + } + } + map + }; + + let mut by_view_cost: HashMap> = HashMap::new(); + for hash in &candidate { + if let Some(name) = owner.get(hash) { + by_view_cost.entry(name.clone()).or_default().push(*hash); + } + } + + let cost = self.aggregate_cost(&candidate)?; + let mut views: Vec = Vec::with_capacity(states.len()); + for state in &states { + let view_cost = + self.aggregate_cost(&by_view_cost.get(&state.name).cloned().unwrap_or_default())?; + let mut files: BTreeSet = BTreeSet::new(); + for hash in by_view_cost.get(&state.name).cloned().unwrap_or_default() { + for path in self.change_modified_paths(&hash)? { + files.insert(path); + } + } + views.push(ViewOutcome { + view: state.name.clone(), + scope: if state.kind.is_draft() { + "draft".to_string() + } else { + "shared".to_string() + }, + parent: state + .parent + .and_then(|pid| self.parent_name(&txn, pid).ok().flatten()), + changes: by_view_cost.get(&state.name).map_or(0, |v| v.len()), + files: files.len(), + cost: view_cost, + }); + } + // Deterministic per-view order regardless of input order. + views.sort_by(|a, b| a.view.cmp(&b.view)); + + // ── Footprint ─────────────────────────────────────────────────────── + let mut paths: BTreeSet = BTreeSet::new(); + let mut baggage_files: BTreeSet = BTreeSet::new(); + let mut baggage = 0usize; + for hash in &candidate { + let (modifies, coverage) = self.change_coverage(hash)?; + for file_id in &modifies { + paths.insert(file_id.trim_start_matches("file:").to_string()); + } + if matches!(coverage, crate::repository::Coverage::Uncovered) { + baggage += 1; + baggage_files.extend(modifies); + } + } + + let footprint = OutcomeFootprint { + changes: candidate.len(), + files: paths.len(), + paths: paths.into_iter().collect(), + baggage, + baggage_files: baggage_files.into_iter().collect(), + }; + + // ── Mergeability ──────────────────────────────────────────────────── + // The filter must be the state a single promotion would produce: the + // target's content plus everything the sources would add. Asking the + // union of {target} ∪ sources for that is exactly right — a source's + // visible set is its delta plus whatever it already shares with the + // target, and that shared part is already in the target's own set, so + // the two formulations are equal. + let mut all_states = vec![target_view.clone()]; + all_states.extend(states.iter().cloned()); + let merge_filter = visible_change_ids_for_views(&txn, &all_states)?; + let mergeability = self.probe_mergeability(&txn, merge_filter, &owner)?; + + // ── Pin and reference ─────────────────────────────────────────────── + let mut source_merkle = BTreeMap::new(); + for state in &states { + source_merkle.insert(state.name.clone(), state.state.to_base32()); + } + let inputs = OutcomeInputs { + target_merkle: target_view.state.to_base32(), + source_merkle, + candidate_changes: candidate.iter().map(|h| h.to_base32()).collect(), + }; + let reference = outcome_reference(&OutcomePins { + sources: sources.to_vec(), + target: target.to_string(), + source_merkle: inputs.source_merkle.clone(), + target_merkle: inputs.target_merkle.clone(), + candidate_changes: inputs.candidate_changes.clone(), + }); + + Ok(Outcome { + sources: sources.to_vec(), + target: target.to_string(), + inputs, + reference, + cost, + footprint, + mergeability, + views, + }) + } + + /// Fold per-change provenance over `hashes` into a cost rollup. + /// + /// Best-effort: an unreadable change increments `unreadable_changes` and is + /// excluded from every other field, so a single corrupt change cannot + /// silently understate the total. + fn aggregate_cost(&self, hashes: &[Hash]) -> Result { + let mut cost = OutcomeCost { + cost_known: false, + ..Default::default() + }; + let mut models: BTreeMap = BTreeMap::new(); + + for hash in hashes { + let change = match self.load_change(hash) { + Ok(change) => change, + Err(_) => { + cost.unreadable_changes += 1; + continue; + } + }; + let provenance = change.provenance(); + if provenance.is_empty() { + cost.unattributed_changes += 1; + continue; + } + cost.attributed_changes += 1; + + let mut seen_in_change: BTreeSet<&str> = BTreeSet::new(); + for prov in provenance { + let entry = models + .entry(prov.model.clone()) + .or_insert_with(|| ModelSpend { + model: prov.model.clone(), + ..Default::default() + }); + if seen_in_change.insert(prov.model.as_str()) { + entry.changes += 1; + } + entry.input_tokens += prov.tokens.input_tokens; + entry.output_tokens += prov.tokens.output_tokens; + entry.cache_read_tokens += prov.tokens.cache_read_tokens; + entry.cache_write_tokens += prov.tokens.cache_write_tokens; + entry.reasoning_tokens += prov.tokens.reasoning_tokens; + entry.micro_usd += prov.cost.micro_usd; + } + } + + for entry in models.values_mut() { + entry.total_tokens = entry.input_tokens + entry.output_tokens; + entry.usd = entry.micro_usd as f64 / 1_000_000.0; + + cost.input_tokens += entry.input_tokens; + cost.output_tokens += entry.output_tokens; + cost.cache_read_tokens += entry.cache_read_tokens; + cost.cache_write_tokens += entry.cache_write_tokens; + cost.reasoning_tokens += entry.reasoning_tokens; + cost.total_tokens += entry.total_tokens; + cost.micro_usd += entry.micro_usd; + } + cost.usd = cost.micro_usd as f64 / 1_000_000.0; + cost.by_model = models.into_values().collect(); + cost.cost_known = cost.attributed_changes > 0; + Ok(cost) + } + + /// Materialize the union into a discard-everything working copy and read + /// back the residual graph conflicts. + /// + /// This is **read-only**: the [`Sink`] working copy writes nowhere, so + /// probing leaves the repository byte-for-byte unchanged. It answers + /// *graph* conflicts (name / order / cyclic / zombie) — not insert-time + /// edge-context failures, which can only be observed by actually inserting. + fn probe_mergeability( + &self, + txn: &atomic_core::pristine::ReadTxn, + filter: HashSet, + owner: &HashMap, + ) -> Result { + let cached = + CachedGraphTxn::new(txn).map_err(|e| RepositoryError::Database(e.to_string()))?; + let sink = atomic_core::output::Sink::new(); + let options = MaterializeOptions::new().with_change_filter(filter); + + let result = materialize_view(&cached, &self.change_store, &sink, options) + .map_err(|e| RepositoryError::Output(format!("{e}")))?; + + // Attribute each conflict to the sources that produced its changes, so + // we can tell a two-way overlap (adjudicable) from a three-way one + // (beyond the merge engine's reach). `owner` is already the partition + // built for the cost rollup, so no second attribution pass is needed. + let mut conflicts: Vec = result + .conflicts + .iter() + .map(|c| { + let mut views: BTreeSet = BTreeSet::new(); + for hash in &c.changes { + if let Some(view) = owner.get(hash) { + views.insert(view.clone()); + } + } + let concurrent = views.len(); + OutcomeConflict { + path: c.path.clone(), + kind: conflict_kind(c.conflict_type).to_string(), + views: views.into_iter().collect(), + concurrent_sources: concurrent, + } + }) + .collect(); + conflicts.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.kind.cmp(&b.kind))); + Ok(classify_conflicts(conflicts)) + } + + /// External hashes for a set of internal change ids, sorted. + fn hashes_of( + &self, + txn: &impl GraphTxnT, + ids: &HashSet, + ) -> Result, RepositoryError> { + let mut hashes = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(hash) = txn + .get_external(*id) + .map_err(|e| RepositoryError::Database(e.to_string()))? + { + hashes.push(hash); + } + } + hashes.sort_by_key(|h| h.to_base32()); + Ok(hashes) + } + + /// Resolve a parent view id to its name, if it still exists. + fn parent_name( + &self, + txn: &impl ViewTxnT, + parent_id: u64, + ) -> Result, RepositoryError> { + Ok(txn + .get_view_by_id(parent_id) + .map_err(|e| RepositoryError::Database(e.to_string()))? + .map(|p| p.name)) + } +} + +/// Why a three-or-more-way overlap cannot be adjudicated automatically. +const NOT_COMPUTABLE_REASON: &str = "at least one conflict spans 3+ source views; the semantic \ + merge engine adjudicates two sides at a time, so this set \ + cannot be verified automatically"; + +/// Decide the mergeability verdict from a set of attributed conflicts. +/// +/// Split out from [`Repository::probe_mergeability`] so the tiering — the part +/// with real logic, and the part that must not lie — is testable without having +/// to hand-construct a graph cycle. That is worth doing deliberately: the merge +/// engine resolves the large majority of overlaps, so an integration test can +/// realistically only ever observe the `Clean` branch. +fn classify_conflicts(mut conflicts: Vec) -> Mergeability { + conflicts.sort_by(|a, b| a.path.cmp(&b.path).then(a.kind.cmp(&b.kind))); + + if conflicts.is_empty() { + return Mergeability::Clean; + } + + // A conflict touching three or more sources is past the two-way merge + // engine's reach, so we can say neither that it merges nor that it does + // not. Reporting it as `NeedsHuman` would be indistinguishable from a + // real pass, which is exactly the unfalsifiable claim this exists to stop. + if conflicts.iter().any(|c| c.concurrent_sources > 2) { + return Mergeability::NotComputable { + reason: NOT_COMPUTABLE_REASON.to_string(), + conflicts, + }; + } + + Mergeability::NeedsHuman { conflicts } +} + +/// Stable lowercase name for a graph conflict kind. +fn conflict_kind(kind: FileConflictType) -> &'static str { + match kind { + FileConflictType::Name => "name", + FileConflictType::Order => "order", + FileConflictType::Cyclic => "cyclic", + FileConflictType::Zombie => "zombie", + FileConflictType::ZombieFile => "zombie_file", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::apply::CrossViewInsertOptions; + use crate::record::RecordOptions; + use crate::tracking::TrackingOptions; + use atomic_core::change::ChangeHeader; + use tempfile::TempDir; + + /// Record every tracked file into a change and apply it. + fn record_all(repo: &Repository, message: &str) -> Hash { + let header = ChangeHeader::new(message); + let options = RecordOptions::new() + .with_all(true) + .save_to_store(true) + .apply_after_record(true) + .enrich_kg(false); + *repo.record(header, options).unwrap().hash() + } + + /// A repo with root view `dev` and one draft `feature-a` parented on it. + fn repo_with_drafts(names: &[&str]) -> (Repository, TempDir) { + let temp = TempDir::new().unwrap(); + let mut repo = Repository::init(temp.path()).unwrap(); + for name in names { + repo.create_draft_view(name, "dev").unwrap(); + } + (repo, temp) + } + + #[test] + fn empty_union_of_an_unchanged_draft_is_clean_and_free() { + let (repo, _temp) = repo_with_drafts(&["feature-a"]); + let outcome = repo.outcome(&["feature-a".to_string()], "dev").unwrap(); + + assert_eq!(outcome.footprint.changes, 0); + assert_eq!(outcome.footprint.files, 0); + assert!(outcome.mergeability.is_clean()); + // Nothing was recorded, so cost is *unknown*, not zero-cost work. + assert!(!outcome.cost.cost_known); + assert_eq!(outcome.cost.attributed_changes, 0); + assert!(outcome.reference.starts_with("urn:atomic:outcome:")); + } + + #[test] + fn outcome_rolls_up_cost_and_footprint_of_a_single_draft() { + let (mut repo, temp) = repo_with_drafts(&["feature-a"]); + repo.set_current_view_in_memory("feature-a"); + + let path = temp.path().join("src/lib.rs"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "fn main() {}\n").unwrap(); + repo.add("src/lib.rs", TrackingOptions::default()).unwrap(); + let hash = record_all(&repo, "add lib"); + + let outcome = repo.outcome(&["feature-a".to_string()], "dev").unwrap(); + assert_eq!(outcome.footprint.changes, 1); + assert_eq!(outcome.footprint.paths, vec!["src/lib.rs".to_string()]); + assert!(outcome.mergeability.is_clean()); + + // The per-view breakdown partitions the union: exactly one view, one + // change, and it matches the total. + assert_eq!(outcome.views.len(), 1); + assert_eq!(outcome.views[0].view, "feature-a"); + assert_eq!(outcome.views[0].scope, "draft"); + assert_eq!(outcome.views[0].parent.as_deref(), Some("dev")); + assert_eq!(outcome.views[0].changes, 1); + assert_eq!(outcome.views[0].files, 1); + // `record_all` records no provenance, so this change is unattributed + // and the report says the cost is *unknown* rather than printing $0.00. + assert_eq!(outcome.views[0].cost.attributed_changes, 0); + assert_eq!(outcome.views[0].cost.unattributed_changes, 1); + assert!(!outcome.views[0].cost.cost_known); + assert!(!outcome.cost.cost_known); + assert_eq!( + outcome.views[0].cost.micro_usd, outcome.cost.micro_usd, + "single-source totals must match the union total" + ); + assert!(outcome.inputs.candidate_changes.contains(&hash.to_base32())); + } + + #[test] + fn union_across_two_drafts_counts_each_change_once() { + let (mut repo, temp) = repo_with_drafts(&["feature-a", "feature-b"]); + + // One change in each draft, touching a different file. + repo.set_current_view_in_memory("feature-a"); + let a = temp.path().join("a.txt"); + std::fs::write(&a, "a\n").unwrap(); + repo.add("a.txt", TrackingOptions::default()).unwrap(); + record_all(&repo, "add a"); + + repo.set_current_view_in_memory("feature-b"); + let b = temp.path().join("b.txt"); + std::fs::write(&b, "b\n").unwrap(); + repo.add("b.txt", TrackingOptions::default()).unwrap(); + record_all(&repo, "add b"); + + let outcome = repo + .outcome(&["feature-a".to_string(), "feature-b".to_string()], "dev") + .unwrap(); + + assert_eq!(outcome.footprint.changes, 2); + assert_eq!(outcome.footprint.paths, vec!["a.txt", "b.txt"]); + assert!(outcome.mergeability.is_clean()); + + // The per-view costs partition the union, so they sum to the total. + let per_view: u64 = outcome.views.iter().map(|v| v.cost.micro_usd).sum(); + assert_eq!(per_view, outcome.cost.micro_usd); + assert_eq!( + outcome.views.iter().map(|v| v.changes).sum::(), + outcome.footprint.changes, + "a change shared by two sources must be attributed to exactly one" + ); + } + + #[test] + fn source_order_does_not_change_the_reference() { + let (mut repo, temp) = repo_with_drafts(&["feature-a", "feature-b"]); + repo.set_current_view_in_memory("feature-a"); + std::fs::write(temp.path().join("a.txt"), "a\n").unwrap(); + repo.add("a.txt", TrackingOptions::default()).unwrap(); + record_all(&repo, "add a"); + + let forward = repo + .outcome(&["feature-a".to_string(), "feature-b".to_string()], "dev") + .unwrap(); + let reverse = repo + .outcome(&["feature-b".to_string(), "feature-a".to_string()], "dev") + .unwrap(); + + assert_eq!(forward.reference, reverse.reference); + // Per-view output is sorted regardless of the order asked for. + let names: Vec<&str> = reverse.views.iter().map(|v| v.view.as_str()).collect(); + assert_eq!(names, vec!["feature-a", "feature-b"]); + } + + #[test] + fn already_promoted_work_contributes_nothing() { + let (mut repo, temp) = repo_with_drafts(&["feature-a"]); + repo.set_current_view_in_memory("feature-a"); + std::fs::write(temp.path().join("a.txt"), "a\n").unwrap(); + repo.add("a.txt", TrackingOptions::default()).unwrap(); + record_all(&repo, "add a"); + + // The change is in `dev` already, so a->dev is an empty delta. + repo.set_current_view_in_memory("dev"); + repo.insert_from_view(CrossViewInsertOptions::new("feature-a", "dev")) + .unwrap(); + + let outcome = repo.outcome(&["feature-a".to_string()], "dev").unwrap(); + assert_eq!(outcome.footprint.changes, 0); + assert!(outcome.mergeability.is_clean()); + } + + /// The mergeability probe asks for `visible_change_ids_for_views(&[target] + /// ++ sources)` instead of `target_visible ∪ union`. Pin that the two are + /// the same set, because the refactor is only valid if they are. + #[test] + fn union_of_target_and_sources_equals_target_visible_plus_deltas() { + let (mut repo, temp) = repo_with_drafts(&["feature-a", "feature-b"]); + + repo.set_current_view_in_memory("feature-a"); + std::fs::write(temp.path().join("a.txt"), "a\n").unwrap(); + repo.add("a.txt", TrackingOptions::default()).unwrap(); + record_all(&repo, "add a"); + + repo.set_current_view_in_memory("feature-b"); + std::fs::write(temp.path().join("b.txt"), "b\n").unwrap(); + repo.add("b.txt", TrackingOptions::default()).unwrap(); + record_all(&repo, "add b"); + + let txn = repo.pristine.read_txn().expect("read txn"); + let dev = txn.get_view("dev").unwrap().unwrap(); + let a = txn.get_view("feature-a").unwrap().unwrap(); + let b = txn.get_view("feature-b").unwrap().unwrap(); + + // The helper's answer. + let via_helper = visible_change_ids_for_views(&txn, &[dev.clone(), a.clone(), b.clone()]) + .expect("union"); + + // The hand-built equivalent it replaced. + let target_visible = collect_visible_change_ids_with_deps(&txn, &dev).unwrap(); + let mut hand_built = target_visible.clone(); + for view in [&a, &b] { + for id in collect_visible_change_ids_with_deps(&txn, view).unwrap() { + if !target_visible.contains(&id) { + hand_built.insert(id); + } + } + } + + assert_eq!(via_helper, hand_built); + // Not vacuous: the set really does carry each draft's own change on + // top of the shared base, so the two deltas are non-empty. + let target_only = collect_visible_change_ids_with_deps(&txn, &dev).unwrap(); + assert!(via_helper.len() > target_only.len()); + } + + #[test] + fn unknown_source_view_is_reported_as_view_not_found() { + let (repo, _temp) = repo_with_drafts(&["feature-a"]); + let err = repo + .outcome(&["feature-a".to_string(), "nope".to_string()], "dev") + .unwrap_err(); + assert!( + matches!(err, RepositoryError::ViewNotFound { .. }), + "unexpected: {err}" + ); + } + + #[test] + fn empty_source_list_is_rejected() { + let (repo, _temp) = repo_with_drafts(&["feature-a"]); + let err = repo.outcome(&[], "dev").unwrap_err(); + assert!( + matches!(err, RepositoryError::InvalidOperation { .. }), + "unexpected: {err}" + ); + } +} + +#[cfg(test)] +mod conflict_tier_tests { + use super::*; + + fn conflict(path: &str, concurrent: usize) -> OutcomeConflict { + OutcomeConflict { + path: path.to_string(), + kind: "order".to_string(), + views: (0..concurrent).map(|i| format!("view-{i}")).collect(), + concurrent_sources: concurrent, + } + } + + #[test] + fn no_conflicts_is_clean() { + assert!(matches!( + classify_conflicts(Vec::new()), + Mergeability::Clean + )); + } + + #[test] + fn one_or_two_way_overlap_is_a_human_problem() { + for concurrent in 1..=2 { + let verdict = classify_conflicts(vec![conflict("src/a.ts", concurrent)]); + match verdict { + Mergeability::NeedsHuman { conflicts } => { + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].concurrent_sources, concurrent); + } + other => panic!("expected NeedsHuman, got {other:?}"), + } + } + } + + #[test] + fn three_way_overlap_is_not_computable_not_a_pass() { + // This is the load-bearing honesty test: a conflict the engine cannot + // adjudicate must never be reported as merely "needs a human". + let verdict = classify_conflicts(vec![conflict("src/a.ts", 3)]); + match verdict { + Mergeability::NotComputable { reason, conflicts } => { + assert!(reason.contains("3+"), "unhelpful reason: {reason}"); + assert_eq!(conflicts.len(), 1); + } + other => panic!( + "a 3-way conflict must be NotComputable, got {other:?} — reporting it as a \ + plain human problem would be indistinguishable from a pass" + ), + } + } + + #[test] + fn one_unadjudicable_conflict_downgrades_the_whole_verdict() { + // A single unadjudicable conflict makes the *set* unverifiable, even + // when every other conflict is a clean two-way overlap. + let verdict = classify_conflicts(vec![conflict("src/a.ts", 2), conflict("src/b.ts", 4)]); + assert!(matches!(verdict, Mergeability::NotComputable { .. })); + } + + #[test] + fn conflicts_are_ordered_deterministically() { + let verdict = classify_conflicts(vec![conflict("src/z.ts", 2), conflict("src/a.ts", 2)]); + match verdict { + Mergeability::NeedsHuman { conflicts } => { + assert_eq!(conflicts[0].path, "src/a.ts"); + assert_eq!(conflicts[1].path, "src/z.ts"); + } + other => panic!("expected NeedsHuman, got {other:?}"), + } + } +} diff --git a/atomic-repository/src/repository/triage.rs b/atomic-repository/src/repository/triage.rs index 6e36da1f..944dc010 100644 --- a/atomic-repository/src/repository/triage.rs +++ b/atomic-repository/src/repository/triage.rs @@ -201,7 +201,10 @@ impl Repository { /// - `Uncovered` if the change modifies files but none are touched. /// - `Unknown` only when the change has no file ops at all (e.g. a load /// failure) — unreachable for a normally recorded change. - fn change_coverage(&self, hash: &Hash) -> Result<(Vec, Coverage), RepositoryError> { + pub(crate) fn change_coverage( + &self, + hash: &Hash, + ) -> Result<(Vec, Coverage), RepositoryError> { // change → file comes from the change's own ops (authoritative), mapped // to the shared `file:` node id the intent side also uses. let modifies: Vec = self