diff --git a/dag/gunbc/recurring_failure_mode/a_roster_edit_judged_against_the_base_tip_not_the_merge_base.dag b/dag/gunbc/recurring_failure_mode/a_roster_edit_judged_against_the_base_tip_not_the_merge_base.dag new file mode 100644 index 00000000000..c0097258896 --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/a_roster_edit_judged_against_the_base_tip_not_the_merge_base.dag @@ -0,0 +1,29 @@ +module gunbc.recurring_failure_mode.a_roster_edit_judged_against_the_base_tip_not_the_merge_base + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data a_roster_edit_judged_against_the_base_tip_not_the_merge_base: RecurringFailureMode = RecurringFailureMode { + identity: "a_roster_edit_judged_against_the_base_tip_not_the_merge_base" as NonEmptyStr, + + receipts: [ + "INVALID STATE: a required-floor judgment of a ROSTER EDIT reads the base side at the base ref's TIP, while the floor's declared comparison is a merge-base one. A branch that edits the roster and then falls behind main is judged as if it authored every roster change main made after the branch point, in reverse. The change it is charged with is not in its diff, and the merge that would land it does not contain that change either: git's three-way merge keeps main's later edit to rows the branch did not touch.", + + "OCCURRENCES, 2026-10-05, two PRs from one parent lane in one hour. gunbc#13333 (clever-raven-680) and gunbc#13341 (sunny-ant-198) each edited v2.workflow.floor_unimported_bare_provider_debt_roster. Main then retired roadmap_launch_deployment_receipt rows. Both floors went red with RosterRetirementChanged (Retired -> ActiveDebt) on rows neither diff touched, and both went green after merging origin/main with no change to the fix. Each lane spent a diagnosis round on a red its diff did not cause.", + + "THE MECHANISM, read from the code, not inferred from the symptom. v1_compiler.cli_run required_floor_runner interface_consumer_planning matches floor_diff_comparison_readout and, on the FreezeBaselineComparison MergeBase arm, resolves `git merge-base base head` before reading the base tree; its comment states the floor's window is the relation the affected-set diff was taken under. Two other readers take floor_diff_comparison_readout()?.base(), the tip, and they read DIFFERENT inputs there. (a) unimported_bare_provider_gate reads the UNIMPORTED-BARE-PROVIDER ROSTER at the tip, through v2.workflow.floor_unimported_bare_provider_debt unimported_bare_provider_roster_at_base (git.Core.Show), and judges the roster edit against it; this is the reader the two occurrences above hit. (b) The cost-debt edit-budget arm of the claim planner passes the tip to cost_debt_changed_witness_ceilings, which runs v2.workflow.floor_cost_debt_edit cost_debt_changed_witness_ceilings_at_base over the changed WITNESS FILE'S SOURCE at that ref, not a roster, to classify the edit and so pick the grandfathered or new-witness budget. Its stale-base consequence is a different one: a witness file main edited after the branch point is classified as if this change made the reverse edit. That is unobserved so far, and reachable by the same mechanism. (c) A third reader, of a third input: gunbc#13332's cost_debt_admitted_identities reads COST-ROSTER MEMBERSHIP at the base side to decide what a change admits; at its head 103a8ea847 it read the tip through base() as well (see HARM AND RUNG). So one readout yields two different base commits in one run, depending on which consumer asks.", + + "HARM AND RUNG. As observed, it is a LOUD false red: the line stops, typed and located, on a fact the diff did not author. That is §5-shaped but mis-aimed, and the cost is real: diagnosis rounds, and a standing incentive to merge main reflexively instead of reading the refusal. The SILENT arm is reachable in the same class through an admission reading. A roster whose base-tip membership decides what a change ADMITS (gunbc#13332's cost_debt_admitted_identities, at its head 103a8ea847, reads floor_diff_comparison_readout()?.base() the same way) charges a behind branch with admitting every row main retired since the branch point. That is spurious work at best, and a refusal attributed to the wrong change at worst. Found at rung 1, mitigated only by authors merging main.", + + "WHY THIS IS NOT stale_claim_survives_its_own_correct_edit. That row is a dependent assertion left un-updated after a correct premise edit. Here no assertion is stale; the base READ is taken at the wrong commit, so a correct roster and a correct rule still disagree.", + + "CEILING 3, structurally guaranteed, with the reason: FreezeBaselineComparison already carries the relation as a closed coproduct, so the base commit a roster judgment reads is decidable from the readout alone. Carrying the RESOLVED base commit on the readout, and deleting the bare base() accessor as a route to a tree, leaves no consumer able to read the tip under a merge-base relation. It does not reach 4 because a consumer handed a commit can still pass a different one to git; the construction removes the accessor that invites it, not git's ability to read any ref.", + + "NEXT-RUNG TRIGGER (a CAPABILITY, not an artifact), classified CAN CLIMB NOW BUT UNBUILT: every required-floor consumer that reads a base-side tree obtains the base COMMIT from one resolution of floor_diff_comparison_readout that applies the MergeBase arm, so interface planning, the unimported-bare-provider roster edit, the cost-debt edit budget and cost-debt admission read the same commit in one run. A per-site merge-base call added to one gate does not discharge this row; it fixes one site and leaves the accessor standing for the next.", + + "RECOGNITION RULE AND REVIEW TELL: a refusal naming rows the diff did not touch, which clears after merging main with no change to the diff, is this class until shown otherwise. In review, grep `.base()` on the comparison readout: every caller that reads a tree with it, rather than printing it, is a member.", + + "DISCHARGED, 2026-10-05 (crisp-raven-17, gunbc#13347), by the trigger above, for all three inputs. THE CONSTRUCTION. v1_compiler.cli_run comparison_window resolve is the one place the relation is read for a tree: the base ref under two-dot, the departure point under merge-base. When it cannot resolve it refuses FreezeBaselineUnobservable or FreezeBaselineUnrelatedHistory and never degrades to a ref. It returns ResolvedComparisonWindow, whose base side is a BaseTreeCommit. That is a newtype whose field is private to that module, so resolve is its only constructor; ResolvedComparisonWindow's fields are private too. v1_compiler.cli_run required_floor_runner FloorBaseTree resolves the window at most once per floor run and lends it to every base-tree reader: interface_consumer_planning; unimported_bare_provider_gate, for input (a), the roster, through unimported_bare_provider_edit_refusals and unimported_bare_provider_roster_source_at_base; the cost-debt edit budget in run_required_floor, for input (b), the changed witness file's source, through cost_debt_changed_witness_ceilings; cost-debt admission, for input (c), cost-roster membership, through cost_debt_admitted_identities and cost_debt_base_tree_extract, the git archive of the base tree the base roster is evaluated over; and two tip readers this row had not named, floor_base_test_decl_census and floor_base_file_read, the second reached through non_fold_residue_changed_row_subjects. collect_frozen_path_deferral_additions_for uses the same resolver. FreezeBaselineComparison base() is deleted, and base_ref() remains for printing. EVIDENCE, IN ORDER OF STRENGTH. (1) THE READER INTERFACE, rung 3, structurally guaranteed at that interface. unimported_bare_provider_roster_source_at_base, unimported_bare_provider_edit_refusals, cost_debt_changed_witness_ceilings and cost_debt_base_tree_extract take a BaseTreeCommit, not a string, so the substitution a review raised does not compile: passing the readout's base_ref() as a string into the roster read. floor_base_file_read and floor_base_test_decl_census take FloorBaseTree. The limit is precise, and it is why this is ceiling 3, not 4: a consumer can still hand any ref to git outside these readers, or add a new reader that takes a string. namespace_baseline reconstruct_base_index still takes a string, because it is a general utility with other callers; the floor's only call passes FloorBaseTree's commit. That substitution was executed once as a one-off probe on this branch, and rustc refused it with E0308 (expected `&BaseTreeCommit`, found `&String`). The probe is not enrolled anywhere, because the crate has no compile-fail harness, so after this change the wall rests on the signatures. (2) THE EXECUTED CONTROL, at the selection boundary. required_floor_runner floor_base_tree_tests floor_base_tree_is_the_departure_point_under_merge_base_and_the_base_under_two_dot builds a real history (the branch departs at P, then main moves to C) and drives FloorBaseTree. Under merge-base every base-tree reader is handed P; before this change, the readers read C. Under two-dot the base tree is C, and an unrelated history refuses FreezeBaselineUnrelatedHistory under merge-base. It runs git and nothing else, so it fits the v1 unit lane's per-test budget. At this head that lane is the rust-unit-tests job of .github/workflows/witnesses.yml, which the aggregate job needs; that job, not this sentence, is the standing of the control. WHAT IT REPLACED, recorded rather than silently dropped: this PR first carried two real-history controls that drove the selection through the judgment: roster_edit_is_judged_at_the_merge_base_not_the_base_tip, through unimported_bare_provider_edit_refusals, and gunbc#13332's cost_debt_row_retired_at_the_tip_but_carried_at_the_merge_base_is_not_admitted, through cost_debt_admitted_by_fold. Both passed locally at head 165da6683e. gunbc#13452 then deleted every v1 Rust test over 100ms that evaluates the .dag corpus, the second of those among them, so this change follows that ruling rather than re-adding them. The judgments are the .dag's own and are claimed there. For input (c) the join, meaning that the reader receives the selected commit, is executed by the cost-debt RED below. For (a) and (b) it rests on the reader signatures in (1), which take BaseTreeCommit or FloorBaseTree. Input (b) shares the commit and type, and has no discriminating control of its own. (3) NO .dag CONTROL IS REACHABLE for the defect itself. Which commit is the base side is decided on the host, and a .dag claim receives a roster already read. A claim over v2.workflow.floor_unimported_bare_provider_debt's edit judgment would only re-check the judgment, which v2.test.claim.floor_unimported_bare_provider_debt_test edit_refuses_a_rewritten_cause_and_a_reverted_retirement already executes on the floor. Its next-rung trigger is the window resolution moving into v2.workflow.floor_diff_observe. INPUT (c), COVERED AFTER gunbc#13332 LANDED. That PR carried its own per-site arm, cost_debt_comparison_base_commit, which this row's trigger says does not discharge it. As agreed with its author (crisp-ram-667), the PR that landed second converted it: the helper is deleted, cost_debt_admitted_identities takes FloorBaseTree, and the base-tree archive takes the BaseTreeCommit. cost_debt_roster_head_closure reads only the head tree and is not a base reader. That PR's RED, cost_debt_row_retired_at_the_tip_but_carried_at_the_merge_base_is_not_admitted, was deleted with the slow v1 tests by gunbc#13452. A fix may not land after its discriminating RED is gone, so the RED is re-established at a grain that runs, required_floor_runner floor_base_tree_tests cost_debt_row_retired_at_the_tip_but_carried_at_the_merge_base_is_not_admitted. It drives cost_debt_admitted_at_base_tree, the selection cost_debt_admitted_identities runs, through FloorBaseTree over a real history, with the roster read by git show and the .dag fold's set difference supplied, so no corpus is evaluated. A row main retired after the branch point is not admitted; the POSITIVE CONTROL, a row the branch really adds, is admitted. MUTANT, executed remotely at gunbc#13347 head 401bd7249a: FloorBaseTree resolving a two-dot window (the tip) instead of the comparison's own makes this RED fail, admitting [t.retired], and makes the selection control fail too, base tree = tip rather than departure point. Unmutated, both pass in 0.05s. No floor helper resolves its own merge base any more.", + ], + evidence: [], +} diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 836799b9be4..e42fe24df2f 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -23811,6 +23811,110 @@ pub fn collect_frozen_path_deferral_additions() -> Result, String> { collect_frozen_path_deferral_additions_for(&workspace_root(), &comparison) } +/// THE BASE-TREE COMMIT AS A TYPE ONLY THE RESOLVER CAN BUILD +/// (`gunbc.recurring_failure_mode` `a_roster_edit_judged_against_the_base_tip_not_the_merge_base`). +/// A base-side reader takes `&BaseTreeCommit`, never a `&str`, and the field is private to this +/// module, so the only constructor is `resolve`, which applies the comparison's relation: the base +/// ref under two-dot, the departure point under merge-base. The tip string `base_ref()` returns does +/// not type-check at a reader. The limit is explicit: a consumer may still hand any ref to git +/// directly, outside these readers, which is why this is a ceiling-3 guarantee and not ceiling 4. +pub mod comparison_window { + use super::FreezeBaselineComparison; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct BaseTreeCommit(String); + + impl BaseTreeCommit { + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl std::fmt::Display for BaseTreeCommit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct ResolvedComparisonWindow { + base_tree: BaseTreeCommit, + head: String, + } + + impl ResolvedComparisonWindow { + pub fn base_tree(&self) -> &BaseTreeCommit { + &self.base_tree + } + + pub fn head(&self) -> &str { + &self.head + } + } + + /// Two-dot compares the exact base; merge-base compares the commit the head departed from. + /// The match may never grow a default: imposing merge-base on a two-dot arm passes growth on a + /// rewritten push, and reading the tip on a merge-base arm attributes the base's own later + /// edits to the change. Each refusal carries a typed cause and never degrades to a ref. + pub(crate) fn resolve( + comparison: &FreezeBaselineComparison, + root: &std::path::Path, + ) -> Result { + let run = |args: &[&str]| -> Result { + std::process::Command::new("git") + .args(args) + .current_dir(root) + .output() + .map_err(|e| format!("git {args:?}: {e}")) + }; + let resolve = |rev: &str, role: &str| -> Result { + let out = run(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("{rev}^{{commit}}"), + ])?; + if !out.status.success() { + return Err(format!( + "cause=FreezeBaselineUnobservable {role}={rev} — one endpoint of the \ + comparison could not be read, so the base side is unobservable. \ + Could-not-read and permits-this are different states and this refuses \ + rather than conflating them. Fetch the ref, or set GUNBC_CI_DIFF_BASE to a \ + resolvable rev." + )); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }; + let base = comparison.base_ref(); + let base_commit = resolve(base, "base")?; + let head = resolve(comparison.head(), "head")?; + let base_tree = match comparison { + FreezeBaselineComparison::Direct { .. } => base_commit, + FreezeBaselineComparison::MergeBase { .. } => { + let merge_base = run(&["merge-base", &base_commit, &head])?; + let fork_point = String::from_utf8_lossy(&merge_base.stdout) + .trim() + .to_string(); + if !merge_base.status.success() || fork_point.is_empty() { + return Err(format!( + "cause=FreezeBaselineUnrelatedHistory base={base} — this comparison is \ + merge-base mode, so the base side is the commit the head DEPARTED from, \ + and git merge-base named no common ancestor. No-common-ancestor and \ + permits-this are different states and this refuses rather than \ + conflating them. (Under two-dot mode the same history is directly \ + comparable and does NOT reach here.)" + )); + } + fork_point + } + }; + Ok(ResolvedComparisonWindow { + base_tree: BaseTreeCommit(base_tree), + head, + }) + } +} + /// THE COMPARISON WINDOW, as the seed sees it. Mirrors `FloorDiffComparisonReadout` arm for arm; /// the mode is a variant rather than a flag because it selects which commit the base side is read /// at, and a bool would let a caller forget to ask. @@ -23834,12 +23938,24 @@ pub enum FreezeBaselineComparison { } impl FreezeBaselineComparison { - fn base(&self) -> &str { + /// The base REF as the authority named it. For printing and locating only: it is not a tree. + /// A reader that needs base-side content takes `resolve_window`'s `base_tree`, because under + /// merge-base mode this ref's tree carries every change the base made after the head departed. + pub(crate) fn base_ref(&self) -> &str { match self { Self::Direct { base, .. } | Self::MergeBase { base, .. } => base, } } + /// THE BASE COMMIT, with the relation applied: the one place the arm is read for a tree + /// (`comparison_window::resolve`). + pub(crate) fn resolve_window( + &self, + root: &std::path::Path, + ) -> Result { + comparison_window::resolve(self, root) + } + fn head(&self) -> &str { match self { Self::Direct { head, .. } | Self::MergeBase { head, .. } => head, @@ -23870,7 +23986,7 @@ pub fn collect_frozen_path_deferral_additions_for( let located = |msg: String| -> String { format!( "{msg} (comparison base={} head={} kind={} mode={})", - comparison.base(), + comparison.base_ref(), comparison.head(), comparison.kind(), match comparison { @@ -23879,59 +23995,14 @@ pub fn collect_frozen_path_deferral_additions_for( } ) }; - let base = comparison.base(); - let resolve = |rev: &str, role: &str| -> Result { - let out = run(&[ - "rev-parse", - "--verify", - "--quiet", - &format!("{rev}^{{commit}}"), - ])?; - if !out.status.success() { - return Err(located(format!( - "WITNESS ADMISSION REFUSAL cause=FreezeBaselineUnobservable {role}={rev} — the \ - frozen path-deferral roster is a monotone debt contract and one endpoint of its \ - comparison could not be read, so growth cannot be ruled out. Could-not-read and \ - permits-this are different states and this arm refuses rather than conflating \ - them. Fetch the ref, or set GUNBC_CI_DIFF_BASE to a resolvable rev." - ))); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - }; - let base_commit = resolve(base, "base")?; - let head_commit = resolve(comparison.head(), "head")?; - - // THE BASELINE COMMIT, and the whole content of the relation half: two-dot compares the exact - // base, merge-base compares the departure point. Choosing one for both is a fork of the - // authority's own decision, and the direction it fails matters — imposing merge-base on a - // two-dot arm passes growth (a fail-open), so this match may never grow a default. - let baseline_commit = match comparison { - FreezeBaselineComparison::Direct { .. } => base_commit, - FreezeBaselineComparison::MergeBase { .. } => { - let merge_base = run(&["merge-base", &base_commit, &head_commit])?; - if !merge_base.status.success() { - return Err(located(format!( - "WITNESS ADMISSION REFUSAL cause=FreezeBaselineUnrelatedHistory base={base} — \ - this comparison is merge-base mode, so the roster is monotone against the \ - commit the head DEPARTED from, and git merge-base found no common ancestor. \ - No-common-ancestor and permits-this are different states and this arm refuses \ - rather than conflating them. (Under two-dot mode the same history is directly \ - comparable and does NOT reach here.)" - ))); - } - let fork_point = String::from_utf8_lossy(&merge_base.stdout) - .trim() - .to_string(); - if fork_point.is_empty() { - return Err(located(format!( - "WITNESS ADMISSION REFUSAL cause=FreezeBaselineUnrelatedHistory base={base} — \ - git merge-base succeeded but named no commit, so the baseline is unobservable \ - and growth cannot be ruled out." - ))); - } - fork_point - } - }; + let base = comparison.base_ref(); + // THE BASELINE COMMIT is the window's base tree: the relation is applied once, by the + // comparison itself, for this gate and every floor base-tree reader alike. + let window = comparison + .resolve_window(root) + .map_err(|cause| located(format!("WITNESS ADMISSION REFUSAL {cause} (frozen path-deferral roster: a monotone debt contract, so growth cannot be ruled out)")))?; + let baseline_commit = window.base_tree().as_str().to_string(); + let head_commit = window.head().to_string(); // THE CURRENT SIDE IS THE SELECTED HEAD, not an ambient one, and this runs BEFORE any arm that // can permit. Reading the live filesystem keeps an uncommitted local roster edit in scope (the diff --git a/src/v1/stage0/src/cli_run/non_fold_residue.rs b/src/v1/stage0/src/cli_run/non_fold_residue.rs index c1af85cba73..116d9d9aeaf 100644 --- a/src/v1/stage0/src/cli_run/non_fold_residue.rs +++ b/src/v1/stage0/src/cli_run/non_fold_residue.rs @@ -533,8 +533,9 @@ pub(crate) fn non_fold_residue_classify_row_subject( /// The subjects of every roster row the diff added or deleted, read at the floor's resolved diff /// base and classified by exact path. Refuses when the base roster cannot be read. pub(crate) fn non_fold_residue_changed_row_subjects( + base_tree: &crate::cli_run::required_floor_runner::FloorBaseTree, ) -> Result, String> { - let base_rows = match floor_base_file_read(NON_FOLD_RESIDUE_AUTHORITY_REL)? { + let base_rows = match floor_base_file_read(base_tree, NON_FOLD_RESIDUE_AUTHORITY_REL)? { None => Vec::new(), Some(content) => { non_fold_residue_units_from_module_source(NON_FOLD_RESIDUE_AUTHORITY_REL, &content) diff --git a/src/v1/stage0/src/cli_run/required_floor_runner.rs b/src/v1/stage0/src/cli_run/required_floor_runner.rs index 7fb2c6cbf8f..adaff4c89c9 100644 --- a/src/v1/stage0/src/cli_run/required_floor_runner.rs +++ b/src/v1/stage0/src/cli_run/required_floor_runner.rs @@ -714,6 +714,89 @@ pub(crate) fn floor_diff_comparison_readout() -> Result, + window: std::cell::OnceCell< + Result< + ( + FreezeBaselineComparison, + comparison_window::ResolvedComparisonWindow, + ), + String, + >, + >, +} + +impl FloorBaseTree { + pub(crate) fn unresolved() -> Self { + Self { + supplied: None, + window: std::cell::OnceCell::new(), + } + } + + /// A supplied comparison over a supplied repository, resolved by the same `resolve_window`. + pub(crate) fn for_comparison(comparison: FreezeBaselineComparison, root: PathBuf) -> Self { + Self { + supplied: Some((comparison, root)), + window: std::cell::OnceCell::new(), + } + } + + fn window( + &self, + ) -> Result< + &( + FreezeBaselineComparison, + comparison_window::ResolvedComparisonWindow, + ), + String, + > { + self.window + .get_or_init(|| { + let (comparison, root) = match &self.supplied { + Some((comparison, root)) => (comparison.clone(), root.clone()), + None => (floor_diff_comparison_readout()?, process_workspace_root()), + }; + let window = comparison.resolve_window(&root)?; + Ok((comparison, window)) + }) + .as_ref() + .map_err(Clone::clone) + } + + /// The commit whose tree is the base side of this run's comparison. + pub(crate) fn commit(&self) -> Result<&comparison_window::BaseTreeCommit, String> { + Ok(self.window()?.1.base_tree()) + } + + /// The resolved head commit of this run's comparison. + pub(crate) fn head(&self) -> Result<&str, String> { + Ok(self.window()?.1.head()) + } + + /// The base REF and the base-tree commit, for a located line. Never a tree route. + pub(crate) fn located(&self) -> Result { + let (comparison, window) = self.window()?; + Ok(format!( + "base_ref={} base_tree={}", + comparison.base_ref(), + window.base_tree() + )) + } +} + pub(crate) fn floor_diff_baseline_readout() -> Result<(String, String), String> { use v1_interpreter::Value; let roots = default_source_roots(); @@ -809,7 +892,7 @@ pub(crate) fn floor_git_diff_name_status_range() -> Result<(Vec, HashSet /// resolves a removed call against. Returns an exact identity map of the model's edit labels and budgets. /// Tokenization is shared only within this observed file pair, never across revisions. pub(crate) fn cost_debt_changed_witness_ceilings( - base: &str, + base_tree: &comparison_window::BaseTreeCommit, rel_path: &str, functions: &[String], head_source: &str, @@ -822,7 +905,7 @@ pub(crate) fn cost_debt_changed_witness_ceilings( .map_err(|e| format!("floor_cost_debt_edit resolve: {e}"))?; let ctx = make_eval_context(&graph, indices, v1_interpreter::ExecutionMode::Wet); let args = [ - (Some("base".to_string()), str_value(base)), + (Some("base".to_string()), str_value(base_tree.as_str())), (Some("path".to_string()), str_value(rel_path)), ( Some("functions".to_string()), @@ -895,16 +978,19 @@ pub(crate) fn cost_debt_changed_witness_ceilings( /// One file's content at the resolved diff base (`v2.workflow.floor_diff_observe` /// `floor_run_base_file_read`): `Ok(None)` when the base does not carry the path, `Err` when the /// listing or show refused -- a refusal is never read as an absent or empty file. -pub(crate) fn floor_base_file_read(path: &str) -> Result, String> { +pub(crate) fn floor_base_file_read( + base_tree: &FloorBaseTree, + path: &str, +) -> Result, String> { use v1_interpreter::Value; - let comparison = floor_diff_comparison_readout()?; + let base = base_tree.commit()?.as_str(); let roots = default_source_roots(); let entry = "src/v2/workflow/floor_diff_observe.dag"; let (graph, indices) = resolve_entry_graph_shared(&roots, entry) .map_err(|e| format!("floor_diff_observe resolve: {e}"))?; let ctx = make_eval_context(&graph, indices, v1_interpreter::ExecutionMode::Wet); let args = [ - (Some("base".to_string()), str_value(comparison.base())), + (Some("base".to_string()), str_value(base)), (Some("path".to_string()), str_value(path)), ]; let result = @@ -943,13 +1029,14 @@ pub(crate) fn floor_base_file_read(path: &str) -> Result, String> /// Authority: `v2.workflow.floor_diff_observe` `floor_run_base_test_decl_census`. A refused /// census is an observation failure and never becomes an empty map. pub(crate) fn floor_base_test_decl_census( + base_tree: &FloorBaseTree, paths: &[String], ) -> Result>, String> { use v1_interpreter::Value; if paths.is_empty() { return Ok(std::collections::HashMap::new()); } - let comparison = floor_diff_comparison_readout()?; + let base = base_tree.commit()?.as_str(); let roots = default_source_roots(); let entry = "src/v2/workflow/floor_diff_observe.dag"; let (graph, indices) = resolve_entry_graph_shared(&roots, entry) @@ -957,7 +1044,7 @@ pub(crate) fn floor_base_test_decl_census( let ctx = make_eval_context(&graph, indices, v1_interpreter::ExecutionMode::Wet); let path_values: Vec = paths.iter().map(|p| str_value(p.clone())).collect(); let args = [ - (Some("base".to_string()), str_value(comparison.base())), + (Some("base".to_string()), str_value(base)), (Some("paths".to_string()), list_value_from_vec(path_values)), ]; let result = v1_interpreter::run_in_context_with_args( @@ -1555,6 +1642,7 @@ pub(crate) struct ChangedWitnessProjectionRow { /// the `changed_witness_identities` wrapper above it (already callerless on `main`) are deleted /// here rather than left for that later consolidation. fn changed_and_enrolled_witness_identities_with_index( + base_tree: &FloorBaseTree, index: &MultiEntryIndex, source_roots: &[String], planning_index: Option<&crate::cli_run::declaration_index::DeclarationIndex>, @@ -1570,6 +1658,7 @@ fn changed_and_enrolled_witness_identities_with_index( // THE SAME DIFF every projection below reads, so the rule and the planned subject cannot // describe different changes. unimported_bare_provider_gate( + base_tree, index, source_roots, &changed_paths, @@ -1588,7 +1677,7 @@ fn changed_and_enrolled_witness_identities_with_index( } let dag_path_list: Vec = dag_paths.into_iter().collect(); floor_seam("diff-base-decl-census"); - let base_test_decl_names = floor_base_test_decl_census(&dag_path_list)?; + let base_test_decl_names = floor_base_test_decl_census(base_tree, &dag_path_list)?; floor_seam("diff-edits"); let edits = floor_diff_edits_from_line_ranges( index, @@ -1621,7 +1710,7 @@ fn changed_and_enrolled_witness_identities_with_index( &changed_paths, &added_paths, cost_debt_roster_head_closure, - cost_debt_admitted_identities, + || cost_debt_admitted_identities(base_tree), || cost_debt_admitted_by_fold(&[], None), )?; // THE THIRD PROJECTION IS THE COMPILE SUBJECT, not another witness roster. A helper-fn @@ -1658,7 +1747,8 @@ fn changed_and_enrolled_witness_identities_with_index( floor_seam("interface-consumer-planning"); let touched_declarations = declaration_seeds_from_touched_declarations(&root, &edits.touched_declarations)?; - let interface_consumers = interface_consumer_planning(planning_index, &touched_declarations)?; + let interface_consumers = + interface_consumer_planning(base_tree, planning_index, &touched_declarations)?; // THE FIFTH PROJECTION IS THE DIFF'S PATHS THEMSELVES, for the checker-input rule // (`v2.workflow.floor_subject_seed` `checker_subject_rule`): a changed non-.dag path is // structural-empty for the .dag frontier above and still an input to every Strict verdict @@ -1778,6 +1868,7 @@ pub(crate) fn declaration_seeds_from_touched_declarations( /// rebuilt. A floor invoked without it on a CI commit is refused below rather than planned /// blind; a local run without a diff baseline never reaches here. fn interface_consumer_planning( + base_tree: &FloorBaseTree, planning_index: Option<&crate::cli_run::declaration_index::DeclarationIndex>, touched_declarations: &std::collections::BTreeSet<(String, String)>, ) -> Result { @@ -1796,15 +1887,8 @@ fn interface_consumer_planning( // direct comparison at the base ref itself -- the same relation the affected-set diff was // taken under, so the interface delta and the line-range attribution describe one change. let workspace = process_workspace_root(); - let (base_commit, head_commit) = match floor_diff_comparison_readout()? { - FreezeBaselineComparison::Direct { base, head, .. } => (base, head), - FreezeBaselineComparison::MergeBase { base, head, .. } => { - let merge_base = git_stdout(&workspace, &["merge-base", &base, &head])?; - (merge_base, head) - } - }; - let base_commit = git_stdout(&workspace, &["rev-parse", &base_commit])?; - let head_commit = git_stdout(&workspace, &["rev-parse", &head_commit])?; + let base_commit = base_tree.commit()?.as_str().to_string(); + let head_commit = base_tree.head()?.to_string(); match reconstruct_base_index(&workspace, &base_commit, &head_commit, head_index)? { BaselineReconstruction::NoSubject { head } => { Ok(InterfaceConsumerPlanning::NoSubject { head }) @@ -2660,18 +2744,32 @@ const FLOOR_COST_DEBT_VERDICT: &str = "src/v2/workflow/floor_cost_debt_verdict.d /// are read at the base commit (`cost_debt_base_tree_extract`), so a provider changed at head /// cannot change the base population (review on gunbc#13332). A roster this change modifies must /// exist and evaluate at the base; anything else refuses rather than reading as carried. -fn cost_debt_admitted_identities() -> Result, String> { - use crate::cli_run::namespace_baseline::git_stdout; - let workspace = process_workspace_root(); - let base_commit = - cost_debt_comparison_base_commit(floor_diff_comparison_readout()?, |base, head| { - git_stdout(&workspace, &["merge-base", base, head]) - })?; - let tree = cost_debt_scratch_dir("base")?; - let read = cost_debt_base_tree_extract(&workspace, &base_commit, &tree) - .and_then(|()| cost_debt_roster_in_tree(&tree)); - std::fs::remove_dir_all(&tree).ok(); - cost_debt_admitted_by_fold(&read?, None) +fn cost_debt_admitted_identities(base_tree: &FloorBaseTree) -> Result, String> { + cost_debt_admitted_at_base_tree( + base_tree, + |base_commit| { + let workspace = process_workspace_root(); + let tree = cost_debt_scratch_dir("base")?; + let read = cost_debt_base_tree_extract(&workspace, base_commit, &tree) + .and_then(|()| cost_debt_roster_in_tree(&tree)); + std::fs::remove_dir_all(&tree).ok(); + read + }, + |base_rows| cost_debt_admitted_by_fold(base_rows, None), + ) +} + +/// THE BASE SIDE OF ADMISSION: the base roster is read at the run's base-tree commit and nowhere +/// else, then folded against head. The read and the fold are parameters so the stale-base RED +/// drives THIS selection with supplied values inside the unit budget (gunbc#13452); production +/// supplies the base-tree extraction and the `.dag` fold above. +fn cost_debt_admitted_at_base_tree( + base_tree: &FloorBaseTree, + read_base_rows: impl FnOnce(&comparison_window::BaseTreeCommit) -> Result, String>, + fold: impl FnOnce(&[String]) -> Result, String>, +) -> Result, String> { + let base_rows = read_base_rows(base_tree.commit()?)?; + fold(&base_rows) } /// A fresh scratch directory under the ignored build tree, one per read. @@ -2691,9 +2789,10 @@ fn cost_debt_scratch_dir(label: &str) -> Result { /// beside. A refused archive or extraction is a typed refusal, never an empty tree. pub(crate) fn cost_debt_base_tree_extract( workspace: &Path, - commit: &str, + base_tree: &comparison_window::BaseTreeCommit, into: &Path, ) -> Result<(), String> { + let commit = base_tree.as_str(); let refuse = |why: String| { format!( "REQUIRED-FLOOR REFUSAL cause=CostDebtBaseRosterUnevaluable base={commit} -- the base \ @@ -2777,21 +2876,6 @@ pub(crate) fn cost_debt_roster_in_tree(tree: &Path) -> Result, Strin .collect() } -/// THE COMMIT THE BASE ROSTER IS READ AT: the floor's own comparison window, exactly as -/// `interface_consumer_planning` reads it. A merge-base comparison reads the merge base, NEVER the -/// base ref's tip: on a branch behind main, a row main retired after the branch point is absent at -/// the tip and present at the merge base and at head, and reading the tip would admit it and -/// charge this change an unbudgeted run for a row it never added (gunbc#13344). -pub(crate) fn cost_debt_comparison_base_commit( - comparison: FreezeBaselineComparison, - merge_base: impl FnOnce(&str, &str) -> Result, -) -> Result { - match comparison { - FreezeBaselineComparison::Direct { base, .. } => Ok(base), - FreezeBaselineComparison::MergeBase { base, head, .. } => merge_base(&base, &head), - } -} - /// The head roster's rows `base_roster` does not carry, by the `.dag` fold. `head_roster` is the /// evaluated head roster when `None`; a supplied head is for controls that plant their own roster. pub(crate) fn cost_debt_admitted_by_fold( @@ -3279,6 +3363,7 @@ fn unimported_bare_provider_standing_refusals( /// that is decided from the diff's own added paths, never from a failed base read. Every refusal is /// reported before the line stops. pub(crate) fn unimported_bare_provider_gate( + base_tree: &FloorBaseTree, index: &MultiEntryIndex, source_roots: &[String], changed_paths: &[String], @@ -3309,19 +3394,12 @@ pub(crate) fn unimported_bare_provider_gate( head.rows.len() ); } else { - let base = floor_diff_comparison_readout()?.base().to_string(); - let base_source = unimported_bare_provider_roster_source_at_base(source_roots, &base)?; - let base_reading = unimported_bare_provider_base_reading(&base_source)?; - let base_coherence = head.refusals( - "unimported_bare_provider_roster_coherence", - &[(Some("rows".to_string()), base_reading.rows_value.clone())], - )?; - for r in base_coherence - .into_iter() - .chain(head.judge_edit(&base_reading, &head)?) - { + let located = base_tree.located()?; + for r in unimported_bare_provider_edit_refusals(base_tree, &head, &head, |base| { + unimported_bare_provider_roster_source_at_base(source_roots, base) + })? { refusals.push(format!( - "{ROUTE} cause=UnimportedBareProvider {r} base={base}" + "{ROUTE} cause=UnimportedBareProvider {r} {located}" )); } } @@ -3391,10 +3469,31 @@ pub fn unimported_bare_provider_entry_refusals( /// The roster's bytes at the diff base, read through the `.dag` (`unimported_bare_provider_roster_at_base`), /// whose unreadable arm refuses here rather than reading as an empty roster. +/// THE ROSTER EDIT JUDGMENT AT THE RUN'S BASE TREE: the base side is read at `base_tree.commit()` +/// and nowhere else, then evaluated for coherence and judged against the head. The reader is the +/// one seam: the gate supplies the `.dag` read (`unimported_bare_provider_roster_source_at_base`), +/// and it can only be handed a `BaseTreeCommit`, so a tip ref does not type-check here. +fn unimported_bare_provider_edit_refusals( + base_tree: &FloorBaseTree, + judge: &UnimportedBareProviderRosterReading, + head: &UnimportedBareProviderRosterReading, + read_base: impl FnOnce(&comparison_window::BaseTreeCommit) -> Result, +) -> Result, String> { + let base_source = read_base(base_tree.commit()?)?; + let base_reading = unimported_bare_provider_base_reading(&base_source)?; + let mut refusals = judge.refusals( + "unimported_bare_provider_roster_coherence", + &[(Some("rows".to_string()), base_reading.rows_value.clone())], + )?; + refusals.extend(judge.judge_edit(&base_reading, head)?); + Ok(refusals) +} + fn unimported_bare_provider_roster_source_at_base( source_roots: &[String], - base: &str, + base_tree: &comparison_window::BaseTreeCommit, ) -> Result { + let base = base_tree.as_str(); let (graph, indices) = resolve_entry_graph_shared( source_roots, &unimported_bare_provider_authority(UNIMPORTED_BARE_PROVIDER_VERDICT), @@ -7959,6 +8058,8 @@ pub fn run_required_floor( // that ancestor. The entry index one line above was already shared for exactly this reason -- // the corpus read simply never was. let floor_corpus = crate::cli_run::read_source_corpus_once(source_roots); + // ONE BASE TREE FOR THE RUN (`FloorBaseTree`): every base-side read below takes this commit. + let floor_base_tree = FloorBaseTree::unresolved(); // ONE DERIVATION, CONSUMED FOUR WAYS. The same diff observation supplies changed-witness // identities, newly enrolled identities, the compile-subject modules of // `touched_entry_files`, and the match-bearing consumers of every coproduct whose arm set @@ -7971,6 +8072,7 @@ pub fn run_required_floor( diff_paths, cost_debt_admitted, ) = match changed_and_enrolled_witness_identities_with_index( + &floor_base_tree, &gate_entry_index, source_roots, planning_index.as_ref(), @@ -8281,9 +8383,9 @@ pub fn run_required_floor( .iter() .any(|m| m == "gunbc.non_fold_residue") => { - crate::cli_run::non_fold_residue_changed_row_subjects().map_err(|e| { - format!("REQUIRED-FLOOR REFUSAL cause=NonFoldResidueRosterBaseUnreadable {e}") - })? + crate::cli_run::non_fold_residue_changed_row_subjects(&floor_base_tree).map_err( + |e| format!("REQUIRED-FLOOR REFUSAL cause=NonFoldResidueRosterBaseUnreadable {e}"), + )? } _ => Vec::new(), }; @@ -9775,7 +9877,7 @@ pub fn run_required_floor( } else if grandfathered_roster.contains(&identity) { grandfathered_eval_step_budget } else if cost_debt_roster.contains(&identity) { - let base = floor_diff_comparison_readout()?.base().to_string(); + let base = floor_base_tree.commit()?; let rel_path = normalize_repo_path(&workspace_relative_repo_path(&file.path)); if file_cost_debt_ceilings.is_none() { let head_source = std::fs::read_to_string(&file.path).map_err(|e| { @@ -9803,7 +9905,7 @@ pub fn run_required_floor( functions.len() ); file_cost_debt_ceilings = Some(cost_debt_changed_witness_ceilings( - &base, &rel_path, &functions, &head_source, + base, &rel_path, &functions, &head_source, &discovered_test_fn_identities, ).map_err(|e| format!( "REQUIRED-FLOOR REFUSAL cause=CostDebtEditUnobserved identity={identity} — {e}" @@ -14389,7 +14491,16 @@ mod changed_witness_projection_tests { fn cost_debt_fixture_tree(provider_rows: &str, roster_has_entry: bool) -> PathBuf { let workspace = process_workspace_root(); let tree = cost_debt_scratch_dir("fixture").expect("scratch"); - cost_debt_base_tree_extract(&workspace, "HEAD", &tree).expect("extract HEAD"); + let head_tree = FloorBaseTree::for_comparison( + FreezeBaselineComparison::Direct { + base: "HEAD".to_string(), + head: "HEAD".to_string(), + kind: "OperatorOverrideBaseline".to_string(), + }, + workspace.clone(), + ); + cost_debt_base_tree_extract(&workspace, head_tree.commit().expect("HEAD"), &tree) + .expect("extract HEAD"); let provider = format!( "module v2.workflow.cost_debt_fixture_provider\n\nimport std.types {{ List }}\n\nfn selected_rows() -> List {{\n {provider_rows}\n}}\n" ); @@ -18381,3 +18492,174 @@ mod changed_selections_outside_discovery_mirror_tests { .collect() } } + +/// THE BASE-TREE SELECTION, over a real history and nothing else (`gunbc.recurring_failure_mode` +/// `a_roster_edit_judged_against_the_base_tip_not_the_merge_base`). Git only, no `.dag` +/// evaluation, so it fits the v1 unit lane's per-test budget (gunbc#13452); the judgments the +/// commit feeds are the `.dag`'s and are claimed there. +#[cfg(test)] +mod floor_base_tree_tests { + use super::*; + + /// The branch departs at P, then main moves on to C. Under the merge-base relation every + /// base-tree reader is handed P (RED before `FloorBaseTree`: the readers read C). Under two-dot + /// it is C, so a rewritten push stays an exact comparison. An unrelated history refuses typed + /// under merge-base rather than degrading to the tip. + #[test] + fn floor_base_tree_is_the_departure_point_under_merge_base_and_the_base_under_two_dot() { + let dir = std::env::temp_dir().join(format!( + "gunbc-floor-base-tree-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir"); + let git = |args: &[&str]| -> String { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&dir) + .output() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + let commit = |msg: &str| { + git(&["commit", "--quiet", "--allow-empty", "-m", msg]); + git(&["rev-parse", "HEAD"]) + }; + git(&["init", "--quiet", "--initial-branch", "main", "."]); + git(&["config", "user.email", "fixture@gunbc.invalid"]); + git(&["config", "user.name", "fixture"]); + let departure = commit("P"); + git(&["checkout", "--quiet", "-b", "subject"]); + let head = commit("H"); + git(&["checkout", "--quiet", "main"]); + let tip = commit("C: main moves after the branch point"); + git(&["checkout", "--quiet", "--orphan", "unrelated"]); + let orphan = commit("U"); + let tree = |merge_base: bool, head: &str| { + let (base, head, kind) = ( + "main".to_string(), + head.to_string(), + "MergeTargetBaseline".to_string(), + ); + FloorBaseTree::for_comparison( + if merge_base { + FreezeBaselineComparison::MergeBase { base, head, kind } + } else { + FreezeBaselineComparison::Direct { base, head, kind } + }, + dir.clone(), + ) + }; + let merge_base = tree(true, &head); + assert_eq!(merge_base.commit().expect("window").as_str(), departure); + assert_eq!(merge_base.head().expect("head"), head); + assert!(merge_base + .located() + .expect("located") + .contains("base_ref=main")); + let direct = tree(false, &head); + assert_eq!(direct.commit().expect("window").as_str(), tip); + let err = tree(true, &orphan) + .commit() + .expect_err("no common ancestor"); + assert!( + err.contains("cause=FreezeBaselineUnrelatedHistory"), + "{err}" + ); + assert!( + tree(false, &orphan).commit().is_ok(), + "two-dot needs no ancestor" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// RED (gunbc#13344, input (c)): a cost-debt row main retired after the branch point is NOT + /// admitted as this change's. Drives `cost_debt_admitted_at_base_tree` through `FloorBaseTree` + /// over a real history, with the roster read by `git show` and the fold supplied as the set + /// difference the `.dag` fold computes (`cost_debt_admitted_identities`), so no corpus is + /// evaluated. POSITIVE CONTROL: a row the branch really adds is admitted. + #[test] + fn cost_debt_row_retired_at_the_tip_but_carried_at_the_merge_base_is_not_admitted() { + let dir = std::env::temp_dir().join(format!( + "gunbc-cost-debt-base-tree-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir"); + let git = |args: &[&str]| -> String { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&dir) + .output() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + let commit = |rows: &str, msg: &str| { + std::fs::write(dir.join("roster.txt"), rows).expect("write"); + git(&["add", "-A"]); + git(&["commit", "--quiet", "--allow-empty", "-m", msg]); + git(&["rev-parse", "HEAD"]) + }; + git(&["init", "--quiet", "--initial-branch", "main", "."]); + git(&["config", "user.email", "fixture@gunbc.invalid"]); + git(&["config", "user.name", "fixture"]); + commit("t.old t.retired", "P"); + git(&["checkout", "--quiet", "-b", "subject"]); + let carried = commit("t.old t.retired", "H: carries the roster unchanged"); + let added = commit("t.old t.retired t.new", "H2: adds t.new"); + git(&["checkout", "--quiet", "main"]); + commit("t.old", "C: main retires t.retired"); + let rows_at = |rev: &str| -> Vec { + git(&["show", &format!("{rev}:roster.txt")]) + .split_whitespace() + .map(str::to_string) + .collect() + }; + let admitted = |head: &str| { + let base_tree = FloorBaseTree::for_comparison( + FreezeBaselineComparison::MergeBase { + base: "main".to_string(), + head: head.to_string(), + kind: "MergeTargetBaseline".to_string(), + }, + dir.clone(), + ); + let head_rows = rows_at(head); + cost_debt_admitted_at_base_tree( + &base_tree, + |base| Ok(rows_at(base.as_str())), + |base_rows| { + Ok(head_rows + .iter() + .filter(|r| !base_rows.contains(r)) + .cloned() + .collect()) + }, + ) + .expect("admission") + }; + assert_eq!( + admitted(&carried), + Vec::::new(), + "t.retired was retired on main after the branch point; it is not this change's" + ); + assert_eq!(admitted(&added), vec!["t.new".to_string()]); + std::fs::remove_dir_all(&dir).ok(); + } +}