Skip to main content

fallow_api/
decision_surface.rs

1//! Decision-surface extractor (stage 6 / 6.G): THE product.
2//!
3//! The apex of the review brief. A change embeds many decisions; almost all are
4//! mechanical and a few are consequential enough to need human taste. This
5//! extractor lifts the consequential STRUCTURAL decisions out of the scattered
6//! diff, frames each as a judgment question, ranks by consequence (blast x
7//! reversibility), caps the surface to a working-memory-sized handful (4 plus or
8//! minus 1), collapses the mechanical remainder, and pairs each decision with the
9//! routed expert ("who to ask").
10//!
11//! ## The SOLID-3 (the ONLY categories that ship)
12//!
13//! Per the verdict (`.plans/agentic-review-e0-verdict.md`) the decision
14//! categories are NOT uniformly reliable on a syntactic engine (ADR-001). Exactly
15//! three are validated and shippable, each backed by a deterministic signal
16//! fallow already emits:
17//!
18//! 1. **coupling/boundary** (`boundary_introduced`): a new cross-zone edge.
19//! 2. **public-API/contract** (`public_api_added` + coordination gaps): a
20//!    new exports-aware public surface, or a changed contract consumed by modules
21//!    outside the diff.
22//! 3. **dependency**: a new `package.json` dependency entry (the arm is present;
23//!    its candidate source is a dependency delta not yet threaded on the brief
24//!    path, so it produces decisions only once that delta lands, never a
25//!    fabricated signal).
26//!
27//! The four CUT categories (abstraction-with-1-implementor, deletion-still-
28//! reachable, convention-divergence, irreversibility/migration) are CONFIRMED
29//! NOISE and MUST NOT ship. `DecisionCategory` has exactly three discriminants,
30//! so a cut category is not even representable: the type system is the guarantee.
31//!
32//! ## The trust mechanism (anti-hallucination)
33//!
34//! Post-validation closes on EXTRACTION, not on framing. Every decision carries a
35//! `signal_id` deterministically derived from the fallow-emitted candidate key it
36//! frames (a delta key or a coordination-gap key). The deterministic layer keeps
37//! the SET of signal_ids it emitted; `DecisionSurface::accept_signal_id` returns
38//! true iff an id is in that set. An agent-proposed decision whose `signal_id` was
39//! never emitted is REJECTED. The agent proposes; the graph disposes.
40
41pub use fallow_output::{
42    Decision, DecisionCategory, DecisionSurface, TruncationNote, build_decision_surface_output,
43};
44use xxhash_rust::xxh3::xxh3_64;
45
46use fallow_output::{ReviewDeltas, RoutingFacts};
47
48/// Default decision-surface cap (the working-memory limit). The surface holds at
49/// most this many ranked decisions; the rest collapse into a truncation note.
50pub const DEFAULT_DECISION_CAP: usize = 4;
51/// Lower bound on the configurable cap (4 minus 1).
52pub const MIN_DECISION_CAP: usize = 3;
53/// Upper bound on the configurable cap (4 plus 1).
54pub const MAX_DECISION_CAP: usize = 5;
55
56/// Derive a deterministic, content-addressed `signal_id` from a category tag plus
57/// the fallow-emitted candidate key. The tag namespaces the key so a boundary key
58/// and a public-API key sharing text never collide. Pure: same inputs always
59/// yield the same id (byte-identical across runs).
60#[must_use]
61pub fn derive_signal_id(category: DecisionCategory, candidate_key: &str) -> String {
62    let mut bytes = Vec::with_capacity(category.tag().len() + 1 + candidate_key.len());
63    bytes.extend_from_slice(category.tag().as_bytes());
64    bytes.push(0);
65    bytes.extend_from_slice(candidate_key.as_bytes());
66    format!("sig:{:016x}", xxh3_64(&bytes))
67}
68
69/// A representative boundary violation used to anchor a coupling/boundary
70/// decision to a file + line. Decoupled from the `fallow_types` finding type so
71/// the extractor unit-tests without constructing full findings.
72#[derive(Debug, Clone)]
73pub struct BoundaryAnchor {
74    /// The R2 zone-pair key (`"<from_zone>->-<to_zone>"`), matching
75    /// `ReviewDeltas::boundary_introduced`.
76    pub zone_pair_key: String,
77    /// Root-relative path of the importing file (the decision anchor).
78    pub from_file: String,
79    /// The `from_zone` of the edge (for the framed question).
80    pub from_zone: String,
81    /// The `to_zone` of the edge (for the framed question).
82    pub to_zone: String,
83    /// 1-based line of the offending import (the suppression anchor).
84    pub line: u32,
85}
86
87/// A coordination gap projected onto the public-API/contract decision shape: a
88/// changed contract consumed by a module outside the diff.
89#[derive(Debug, Clone)]
90pub struct CoordinationAnchor {
91    /// Root-relative path of the changed file whose contract is consumed elsewhere.
92    pub changed_file: String,
93    /// The consumed symbol names (the contract).
94    pub consumed_symbols: Vec<String>,
95    /// Count of distinct non-diff consumers of this changed file's contract.
96    pub consumer_count: u64,
97    /// 1-based line of the contract symbol's declaration in `changed_file`, so the
98    /// decision deep-links / inline-anchors to the exact export. `0` when the line
99    /// could not be resolved (graph not retained or file unreadable).
100    pub line: u32,
101}
102
103/// All inputs the extractor needs, gathered from the assembled brief data.
104pub struct DecisionInputs<'a> {
105    /// Diff-aware deltas (boundary + public-API). The candidate source.
106    pub deltas: &'a ReviewDeltas,
107    /// Boundary anchors keyed by zone-pair, one representative per introduced edge.
108    pub boundary_anchors: &'a [BoundaryAnchor],
109    /// Coordination gaps projected to the contract decision shape.
110    pub coordination: &'a [CoordinationAnchor],
111    /// 1-based line of the first widened public-API export's declaration, so the
112    /// public-API-surface decision anchors to a real line. `0` when unresolved.
113    pub public_api_anchor_line: u32,
114    /// Project-wide fan-in beyond the diff (impact-closure `affected_not_shown`).
115    /// Used as the blast magnitude for boundary + public-API-surface decisions.
116    pub affected_not_shown: u64,
117    /// Ownership routing (routed expert per file).
118    pub routing: &'a RoutingFacts,
119    /// Per-anchor-file head source, for suppression checks. `None` for a file
120    /// whose head content could not be read (the decision is then not suppressed).
121    pub head_source: &'a dyn Fn(&str) -> Option<String>,
122    /// Resolve a head (post-rename) root-relative path to its pre-rename path, from
123    /// the diff's rename pairs. `None` when the file was not renamed. Lets each
124    /// decision carry a `previous_signal_id` so review memory survives a `git mv`.
125    pub rename_old_path: &'a dyn Fn(&str) -> Option<String>,
126    /// Honest per-anchor in-repo out-of-diff consumer count, precomputed from the
127    /// retained graph's reverse-deps before it was dropped. `0` for an anchor with
128    /// no recorded importers (a genuinely new file). The display number; distinct
129    /// from `affected_not_shown` (the project-wide ranking proxy).
130    pub internal_consumers: &'a dyn Fn(&str) -> u64,
131    /// The decision cap (default 4, clamped to [3, 5] by the caller).
132    pub cap: usize,
133}
134
135/// Resolve the routed expert(s) + bus-factor flag for a decision's anchor file.
136fn route_for(routing: &RoutingFacts, anchor_file: &str) -> (Vec<String>, bool) {
137    routing
138        .units
139        .iter()
140        .find(|unit| unit.file == anchor_file)
141        .map_or((Vec::new(), false), |unit| {
142            (unit.expert.clone(), unit.bus_factor_one)
143        })
144}
145
146/// Whether the head source of `anchor_file` suppresses a decision of `category`
147/// at (1-based) `line`. Honors a file-level `fallow-ignore-file` and a
148/// line-level `fallow-ignore-next-line` immediately above the anchor line, in
149/// both the category-scoped (`decision-surface` / category tag) and bare forms.
150fn is_decision_suppressed(
151    head_source: Option<&str>,
152    category: DecisionCategory,
153    line: u32,
154) -> bool {
155    let Some(source) = head_source else {
156        return false;
157    };
158    let lines: Vec<&str> = source.lines().collect();
159    let token_matches = |comment: &str| {
160        if !comment.contains("fallow-ignore") {
161            return false;
162        }
163        // A bare ignore (no kind) suppresses; a kinded ignore must name the
164        // decision-surface family or this decision's category tag.
165        let after = comment
166            .split_once("fallow-ignore-file")
167            .or_else(|| comment.split_once("fallow-ignore-next-line"))
168            .map(|(_, rest)| rest.trim());
169        match after {
170            None => false,
171            Some("") => true,
172            Some(rest) => {
173                rest.contains("decision-surface")
174                    || rest.contains("decision-surfaces")
175                    || rest.contains(category.tag())
176            }
177        }
178    };
179
180    // File-level: any line carrying a file-level ignore.
181    if lines
182        .iter()
183        .any(|l| l.contains("fallow-ignore-file") && token_matches(l))
184    {
185        return true;
186    }
187    // Line-level: the comment sits immediately above the 1-based anchor line.
188    if line >= 2
189        && let Some(prev) = lines.get((line - 2) as usize)
190        && prev.contains("fallow-ignore-next-line")
191        && token_matches(prev)
192    {
193        return true;
194    }
195    false
196}
197
198/// Frame a coupling/boundary decision as a judgment question.
199fn boundary_question(from_zone: &str, to_zone: &str) -> String {
200    format!(
201        "`{from_zone}` now imports `{to_zone}` for the first time. Intended coupling, or should this edge not exist?"
202    )
203}
204
205/// Frame the (batch-consolidated, R1) public-API-surface decision.
206fn public_api_question(count: usize) -> String {
207    format!(
208        "This change adds {count} export{} to the public API surface. Intended as maintained contracts, or should they stay internal?",
209        if count == 1 { "" } else { "s" }
210    )
211}
212
213/// Frame a coordination-gap (contract consumed outside the diff) decision.
214fn coordination_question(changed_file: &str, symbols: &[String], consumers: u64) -> String {
215    format!(
216        "`{changed_file}` changes {} ({}) imported by {consumers} {} outside this PR. Does this change break or alter what those callers expect?",
217        if symbols.len() == 1 {
218            "export"
219        } else {
220            "exports"
221        },
222        symbols.join(", "),
223        if consumers == 1 { "file" } else { "files" }
224    )
225}
226
227/// Pluralize "module" against a count.
228fn modules_word(n: u64) -> &'static str {
229    if n == 1 { "module" } else { "modules" }
230}
231
232/// Subject-verb agreement for the per-clause count: a singular subject takes the
233/// "-s" verb form ("1 module depends"), plural drops it ("2 modules depend").
234fn agrees(verb_plural: &str, n: u64) -> String {
235    if n == 1 {
236        format!("{verb_plural}s")
237    } else {
238        verb_plural.to_string()
239    }
240}
241
242/// The named structural sacrifice for a coupling/boundary decision, as a FACT.
243/// `consumers` is the honest in-repo out-of-diff count for the anchor.
244fn boundary_tradeoff(from_zone: &str, to_zone: &str, consumers: u64) -> String {
245    format!(
246        "Couples `{from_zone}` to `{to_zone}`; {consumers} in-repo {} already {} on this anchor.",
247        modules_word(consumers),
248        agrees("depend", consumers)
249    )
250}
251
252/// The named structural sacrifice for the public-API-surface decision, as a FACT.
253/// The internal count is internal-only, so the clause also names the external
254/// contract risk in prose (it cannot count a published library's downstream).
255fn public_api_tradeoff(count: usize, consumers: u64) -> String {
256    format!(
257        "Adds {count} maintained contract{}; {consumers} in-repo {} already {} this surface, and any external consumers become a contract you cannot remove without a breaking change.",
258        if count == 1 { "" } else { "s" },
259        modules_word(consumers),
260        agrees("consume", consumers)
261    )
262}
263
264/// The named structural sacrifice for a coordination-gap decision, as a FACT.
265fn coordination_tradeoff(consumers: u64) -> String {
266    format!(
267        "{consumers} {} outside the diff {} this contract; changing its shape requires coordinating them.",
268        modules_word(consumers),
269        agrees("consume", consumers)
270    )
271}
272
273/// The per-decision fields for [`build_decision`], distinct from the shared
274/// run context carried in [`DecisionInputs`].
275struct DecisionSpec {
276    category: DecisionCategory,
277    candidate_key: String,
278    question: String,
279    anchor_file: String,
280    anchor_line: u32,
281    blast: u64,
282    /// Honest per-decision in-repo out-of-diff consumer count (display number).
283    internal_consumer_count: u64,
284    /// The named-sacrifice clause, stated as a fact.
285    tradeoff: String,
286}
287
288/// Build one decision, resolving its routed expert and suppression state.
289fn build_decision(spec: DecisionSpec, inputs: &DecisionInputs<'_>) -> Decision {
290    let DecisionSpec {
291        category,
292        candidate_key,
293        question,
294        anchor_file,
295        anchor_line,
296        blast,
297        internal_consumer_count,
298        tradeoff,
299    } = spec;
300    let signal_id = derive_signal_id(category, &candidate_key);
301    // Rename-durable review memory: if any path embedded in the candidate key was
302    // renamed, derive the signal_id this decision WOULD have had under the old
303    // path so the cloud can carry a prior dismissal across the move.
304    let previous_signal_id = remap_key_paths(&candidate_key, inputs.rename_old_path)
305        .map(|old_key| derive_signal_id(category, &old_key));
306    let (expert, bus_factor_one) = route_for(inputs.routing, &anchor_file);
307    let consequence = blast.saturating_mul(category.reversibility_weight());
308    Decision {
309        signal_id,
310        category,
311        question,
312        anchor_file,
313        anchor_line,
314        signal_key: candidate_key,
315        previous_signal_id,
316        blast,
317        consequence,
318        expert,
319        bus_factor_one,
320        internal_consumer_count,
321        tradeoff,
322    }
323}
324
325/// Rebuild a candidate key with every embedded rel path swapped to its pre-rename
326/// form via `rename_old_path`. The key embeds paths as `contract:<path>` or as
327/// `|`-joined `<path>::<name>` components (boundary zone-pair keys carry no path).
328/// Returns the rebuilt, re-sorted key iff at least one path moved, else `None`.
329fn remap_key_paths(key: &str, rename_old_path: &dyn Fn(&str) -> Option<String>) -> Option<String> {
330    let mut moved = false;
331    let mut parts: Vec<String> = key
332        .split('|')
333        .map(|segment| {
334            if let Some(path) = segment.strip_prefix("contract:")
335                && let Some(old) = rename_old_path(path)
336            {
337                moved = true;
338                return format!("contract:{old}");
339            } else if let Some((path, name)) = segment.split_once("::")
340                && let Some(old) = rename_old_path(path)
341            {
342                moved = true;
343                return format!("{old}::{name}");
344            }
345            segment.to_string()
346        })
347        .collect();
348    if !moved {
349        return None;
350    }
351    // The public-API key is the SORTED added-key set joined; re-sort so the rebuilt
352    // key matches what the pre-rename change would have emitted.
353    parts.sort();
354    Some(parts.join("|"))
355}
356
357/// Classify the candidate signals into framed decisions (pre-rank, pre-cap).
358fn classify_candidates(inputs: &DecisionInputs<'_>) -> Vec<Decision> {
359    let mut decisions: Vec<Decision> = Vec::new();
360
361    // (1) Coupling/boundary: one decision per introduced zone-pair edge (R2).
362    for key in &inputs.deltas.boundary_introduced {
363        let anchor = inputs
364            .boundary_anchors
365            .iter()
366            .find(|a| &a.zone_pair_key == key);
367        let (anchor_file, anchor_line, from_zone, to_zone) = anchor.map_or_else(
368            || (String::new(), 0, key.clone(), String::new()),
369            |a| {
370                (
371                    a.from_file.clone(),
372                    a.line,
373                    a.from_zone.clone(),
374                    a.to_zone.clone(),
375                )
376            },
377        );
378        let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
379        decisions.push(build_decision(
380            DecisionSpec {
381                category: DecisionCategory::CouplingBoundary,
382                candidate_key: key.clone(),
383                question: boundary_question(&from_zone, &to_zone),
384                tradeoff: boundary_tradeoff(&from_zone, &to_zone, internal_consumer_count),
385                anchor_file,
386                anchor_line,
387                blast: inputs.affected_not_shown,
388                internal_consumer_count,
389            },
390            inputs,
391        ));
392    }
393
394    // (2a) Public-API surface: R1 batch-consolidate to ONE decision per change.
395    if !inputs.deltas.public_api_added.is_empty() {
396        // The candidate key is the full sorted added-key set joined: one stable
397        // id per change, never one-per-symbol (kills the 111-export noise).
398        let key = inputs.deltas.public_api_added.join("|");
399        let anchor_file = inputs
400            .deltas
401            .public_api_added
402            .first()
403            .and_then(|k| k.split("::").next())
404            .map(str::to_string)
405            .unwrap_or_default();
406        let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
407        decisions.push(build_decision(
408            DecisionSpec {
409                category: DecisionCategory::PublicApiContract,
410                candidate_key: key,
411                question: public_api_question(inputs.deltas.public_api_added.len()),
412                tradeoff: public_api_tradeoff(
413                    inputs.deltas.public_api_added.len(),
414                    internal_consumer_count,
415                ),
416                anchor_file,
417                anchor_line: inputs.public_api_anchor_line,
418                blast: inputs.affected_not_shown,
419                internal_consumer_count,
420            },
421            inputs,
422        ));
423    }
424
425    // (2b) Coordination gaps: a changed contract consumed outside the diff. One
426    // decision per (changed file) contract, keyed on the changed file path.
427    for gap in inputs.coordination {
428        let key = format!("contract:{}", gap.changed_file);
429        decisions.push(build_decision(
430            DecisionSpec {
431                category: DecisionCategory::PublicApiContract,
432                candidate_key: key,
433                question: coordination_question(
434                    &gap.changed_file,
435                    &gap.consumed_symbols,
436                    gap.consumer_count,
437                ),
438                tradeoff: coordination_tradeoff(gap.consumer_count),
439                anchor_file: gap.changed_file.clone(),
440                anchor_line: gap.line,
441                blast: gap.consumer_count,
442                // The coordination arm already carries the honest per-decision
443                // count; no precomputed-map lookup needed.
444                internal_consumer_count: gap.consumer_count,
445            },
446            inputs,
447        ));
448    }
449
450    decisions
451}
452
453/// Extract the full decision surface from the assembled brief inputs: classify
454/// the SOLID-3 candidates, anchor each `signal_id`, rank by consequence, cap to
455/// the working-memory limit, collapse the rest, and drop suppressed decisions.
456///
457/// The emitted-signal-id allowlist is built over EVERY classified decision
458/// (before the cap and before suppression drops), so `accept_signal_id` still
459/// recognizes a collapsed-or-suppressed decision's anchor as fallow-emitted.
460#[must_use]
461pub fn extract_decision_surface(inputs: &DecisionInputs<'_>) -> DecisionSurface {
462    let cap = inputs.cap.clamp(MIN_DECISION_CAP, MAX_DECISION_CAP);
463
464    let mut classified = classify_candidates(inputs);
465
466    // The allowlist: every signal_id the deterministic layer emitted.
467    let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
468
469    // Drop suppressed decisions (suppression parity): a `// fallow-ignore` on the
470    // anchor hides the decision. Done BEFORE the cap so a suppressed decision does
471    // not consume a slot. The signal_id stays on the allowlist (anchor is still a
472    // real fallow signal), so an agent re-proposing it is not "hallucinating".
473    classified.retain(|d| {
474        let source = (inputs.head_source)(&d.anchor_file);
475        !is_decision_suppressed(source.as_deref(), d.category, d.anchor_line)
476    });
477
478    // Rank by consequence desc; stable, deterministic tiebreak on signal_id.
479    classified.sort_by(|a, b| {
480        b.consequence
481            .cmp(&a.consequence)
482            .then_with(|| a.signal_id.cmp(&b.signal_id))
483    });
484
485    let total = classified.len();
486    let truncated = if total > cap {
487        let collapsed = total - cap;
488        classified.truncate(cap);
489        Some(TruncationNote {
490            collapsed,
491            reason: format!(
492                "{collapsed} more structural decision{} collapsed below the cap of {cap}",
493                if collapsed == 1 { "" } else { "s" }
494            ),
495        })
496    } else {
497        None
498    };
499
500    DecisionSurface {
501        decisions: classified,
502        truncated,
503        emitted_signal_ids,
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use fallow_output::RoutingUnit;
511
512    fn deltas(boundary: &[&str], public_api: &[&str]) -> ReviewDeltas {
513        ReviewDeltas {
514            boundary_introduced: boundary.iter().map(|s| (*s).to_string()).collect(),
515            cycle_introduced: Vec::new(),
516            public_api_added: public_api.iter().map(|s| (*s).to_string()).collect(),
517        }
518    }
519
520    fn no_source(_: &str) -> Option<String> {
521        None
522    }
523
524    fn no_consumers(_: &str) -> u64 {
525        0
526    }
527
528    fn inputs<'a>(
529        deltas: &'a ReviewDeltas,
530        boundary_anchors: &'a [BoundaryAnchor],
531        coordination: &'a [CoordinationAnchor],
532        routing: &'a RoutingFacts,
533        head_source: &'a dyn Fn(&str) -> Option<String>,
534        cap: usize,
535    ) -> DecisionInputs<'a> {
536        DecisionInputs {
537            deltas,
538            boundary_anchors,
539            coordination,
540            public_api_anchor_line: 0,
541            affected_not_shown: 3,
542            routing,
543            head_source,
544            rename_old_path: &no_source,
545            internal_consumers: &no_consumers,
546            cap,
547        }
548    }
549
550    fn empty_routing() -> RoutingFacts {
551        RoutingFacts::default()
552    }
553
554    // (d) None of the four cut categories can ever appear: the enum has exactly
555    // three discriminants, so this is a compile-time + runtime guarantee.
556    #[test]
557    fn only_three_categories_exist_no_cut_category_representable() {
558        let all = [
559            DecisionCategory::CouplingBoundary,
560            DecisionCategory::PublicApiContract,
561            DecisionCategory::Dependency,
562        ];
563        assert_eq!(all.len(), 3);
564        // Serialized tags never include a cut-category name.
565        for c in all {
566            let tag = c.tag();
567            for cut in ["abstraction", "deletion", "convention", "irreversib"] {
568                assert!(!tag.contains(cut), "cut category {cut} leaked into {tag}");
569            }
570        }
571    }
572
573    // (a) Every surfaced decision has a signal_id fallow emitted.
574    #[test]
575    fn every_decision_signal_id_resolves_to_an_emitted_candidate() {
576        let d = deltas(&["ui->-db"], &["src/api.ts::Widget"]);
577        let anchors = vec![BoundaryAnchor {
578            zone_pair_key: "ui->-db".to_string(),
579            from_file: "src/ui/page.ts".to_string(),
580            from_zone: "ui".to_string(),
581            to_zone: "db".to_string(),
582            line: 4,
583        }];
584        let routing = empty_routing();
585        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
586        assert!(!surface.decisions.is_empty());
587        for decision in &surface.decisions {
588            assert!(
589                surface.accept_signal_id(&decision.signal_id),
590                "decision {} has an unanchored signal_id",
591                decision.question
592            );
593        }
594    }
595
596    // (b) An injected decision with no signal anchor is REJECTED.
597    #[test]
598    fn injected_unanchored_signal_id_is_rejected() {
599        let d = deltas(&["ui->-db"], &[]);
600        let anchors = vec![BoundaryAnchor {
601            zone_pair_key: "ui->-db".to_string(),
602            from_file: "src/ui/page.ts".to_string(),
603            from_zone: "ui".to_string(),
604            to_zone: "db".to_string(),
605            line: 1,
606        }];
607        let routing = empty_routing();
608        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
609        // A fabricated id the deterministic layer never emitted.
610        assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
611        assert!(!surface.accept_signal_id("sig:0000000000000000"));
612        // The real one is accepted.
613        let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
614        assert!(surface.accept_signal_id(&real));
615    }
616
617    // (c) A >cap input is capped to 4 plus/minus 1 with a truncation reason.
618    #[test]
619    fn over_cap_input_is_capped_with_truncation_reason() {
620        // 6 boundary edges; default cap 4.
621        let d = deltas(&["a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x"], &[]);
622        let routing = empty_routing();
623        let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
624        assert_eq!(surface.decisions.len(), 4, "capped to default 4");
625        let note = surface.truncated.expect("truncation note present");
626        assert_eq!(note.collapsed, 2);
627        assert!(note.reason.contains("collapsed"));
628        assert!(note.reason.contains('2'));
629    }
630
631    #[test]
632    fn cap_is_clamped_to_the_4_plus_minus_1_band() {
633        let d = deltas(
634            &[
635                "a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x", "g->-x",
636            ],
637            &[],
638        );
639        let routing = empty_routing();
640        // cap=10 clamps to MAX (5).
641        let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
642        assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
643        // cap=1 clamps to MIN (3).
644        let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
645        assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
646    }
647
648    // (e) A `// fallow-ignore` suppresses a flagged decision.
649    #[test]
650    fn fallow_ignore_suppresses_a_flagged_decision() {
651        let d = deltas(&["ui->-db"], &[]);
652        let anchors = vec![BoundaryAnchor {
653            zone_pair_key: "ui->-db".to_string(),
654            from_file: "src/ui/page.ts".to_string(),
655            from_zone: "ui".to_string(),
656            to_zone: "db".to_string(),
657            line: 3,
658        }];
659        let routing = empty_routing();
660
661        // No suppression: one decision surfaces.
662        let unsuppressed =
663            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
664        assert_eq!(unsuppressed.decisions.len(), 1);
665
666        // File-level suppression hides it.
667        let file_src = |f: &str| {
668            (f == "src/ui/page.ts").then(|| {
669                "// fallow-ignore-file decision-surface\nimport db from 'db';\n".to_string()
670            })
671        };
672        let suppressed =
673            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &file_src, 4));
674        assert!(
675            suppressed.decisions.is_empty(),
676            "file-level ignore hides it"
677        );
678        // But the signal id stays on the allowlist (the anchor is still real).
679        let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
680        assert!(suppressed.accept_signal_id(&id));
681
682        // Line-level suppression immediately above the anchor line also hides it.
683        let line_src = |f: &str| {
684            (f == "src/ui/page.ts").then(|| {
685                "line1\n// fallow-ignore-next-line decision-surface\nimport db from 'db';\n"
686                    .to_string()
687            })
688        };
689        let line_suppressed =
690            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &line_src, 4));
691        assert!(
692            line_suppressed.decisions.is_empty(),
693            "line-level ignore hides it"
694        );
695    }
696
697    #[test]
698    fn bare_blanket_ignore_suppresses_without_a_kind() {
699        let d = deltas(&["ui->-db"], &[]);
700        let anchors = vec![BoundaryAnchor {
701            zone_pair_key: "ui->-db".to_string(),
702            from_file: "src/ui/page.ts".to_string(),
703            from_zone: "ui".to_string(),
704            to_zone: "db".to_string(),
705            line: 2,
706        }];
707        let routing = empty_routing();
708        let bare = |f: &str| {
709            (f == "src/ui/page.ts")
710                .then(|| "// fallow-ignore-next-line\nimport db from 'db';\n".to_string())
711        };
712        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &bare, 4));
713        assert!(surface.decisions.is_empty(), "bare blanket ignore hides it");
714    }
715
716    #[test]
717    fn unrelated_kind_ignore_does_not_suppress() {
718        let d = deltas(&["ui->-db"], &[]);
719        let anchors = vec![BoundaryAnchor {
720            zone_pair_key: "ui->-db".to_string(),
721            from_file: "src/ui/page.ts".to_string(),
722            from_zone: "ui".to_string(),
723            to_zone: "db".to_string(),
724            line: 2,
725        }];
726        let routing = empty_routing();
727        let other = |f: &str| {
728            (f == "src/ui/page.ts").then(|| {
729                "// fallow-ignore-next-line unused-export\nimport db from 'db';\n".to_string()
730            })
731        };
732        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &other, 4));
733        assert_eq!(
734            surface.decisions.len(),
735            1,
736            "an ignore naming a different kind must not suppress a decision"
737        );
738    }
739
740    #[test]
741    fn routed_expert_is_paired_with_a_decision() {
742        let d = deltas(&["ui->-db"], &[]);
743        let anchors = vec![BoundaryAnchor {
744            zone_pair_key: "ui->-db".to_string(),
745            from_file: "src/ui/page.ts".to_string(),
746            from_zone: "ui".to_string(),
747            to_zone: "db".to_string(),
748            line: 1,
749        }];
750        let routing = RoutingFacts {
751            units: vec![RoutingUnit {
752                file: "src/ui/page.ts".to_string(),
753                expert: vec!["@team/ui".to_string()],
754                bus_factor_one: true,
755            }],
756        };
757        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
758        assert_eq!(surface.decisions.len(), 1);
759        assert_eq!(surface.decisions[0].expert, vec!["@team/ui".to_string()]);
760        assert!(surface.decisions[0].bus_factor_one);
761    }
762
763    #[test]
764    fn public_api_is_batch_consolidated_to_one_decision_r1() {
765        // 111 added export keys collapse to ONE public-API decision (R1).
766        let keys: Vec<String> = (0..111).map(|i| format!("src/ui/index.ts::C{i}")).collect();
767        let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
768        let d = deltas(&[], &key_refs);
769        let routing = empty_routing();
770        let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
771        let public_api_count = surface
772            .decisions
773            .iter()
774            .filter(|dec| dec.category == DecisionCategory::PublicApiContract)
775            .count();
776        assert_eq!(
777            public_api_count, 1,
778            "R1: one public-API decision per change"
779        );
780        assert!(surface.decisions[0].question.contains("111"));
781    }
782
783    #[test]
784    fn public_api_decision_carries_honest_consumer_count_and_tradeoff() {
785        // A public-API delta whose anchor has 7 in-repo out-of-diff consumers must
786        // surface that honest number on the decision AND name it as a fact in the
787        // trade-off clause, distinct from the project-wide ranking proxy (`blast`).
788        let d = deltas(&[], &["src/ui/index.ts::Widget"]);
789        let routing = empty_routing();
790        let seven = |_: &str| 7u64;
791        let surface = extract_decision_surface(&DecisionInputs {
792            deltas: &d,
793            boundary_anchors: &[],
794            coordination: &[],
795            public_api_anchor_line: 0,
796            // The project-wide proxy must NOT become the display number.
797            affected_not_shown: 99,
798            routing: &routing,
799            head_source: &no_source,
800            rename_old_path: &no_source,
801            internal_consumers: &seven,
802            cap: 4,
803        });
804        let dec = surface
805            .decisions
806            .iter()
807            .find(|dec| dec.category == DecisionCategory::PublicApiContract)
808            .expect("a public-API decision");
809        assert_eq!(dec.internal_consumer_count, 7, "honest per-anchor count");
810        assert_ne!(
811            dec.internal_consumer_count, dec.blast,
812            "display number must stay distinct from the ranking proxy"
813        );
814        assert!(
815            dec.tradeoff.contains("7 in-repo"),
816            "trade-off clause states the count as a fact: {}",
817            dec.tradeoff
818        );
819        assert!(
820            dec.question.ends_with('?'),
821            "the decision stays a question (taste ownership)"
822        );
823    }
824
825    #[test]
826    fn coordination_gap_becomes_a_public_api_contract_decision() {
827        let d = deltas(&[], &[]);
828        let coordination = vec![CoordinationAnchor {
829            changed_file: "src/core.ts".to_string(),
830            consumed_symbols: vec!["compute".to_string()],
831            consumer_count: 4,
832            line: 7,
833        }];
834        let routing = empty_routing();
835        let surface =
836            extract_decision_surface(&inputs(&d, &[], &coordination, &routing, &no_source, 4));
837        assert_eq!(surface.decisions.len(), 1);
838        assert_eq!(
839            surface.decisions[0].category,
840            DecisionCategory::PublicApiContract
841        );
842        assert_eq!(surface.decisions[0].blast, 4);
843        // The contract symbol's declaration line flows onto the decision so a PR
844        // review can anchor an inline comment to the exact export.
845        assert_eq!(surface.decisions[0].anchor_line, 7);
846        // No rename in this change -> no previous_signal_id (the default).
847        assert!(surface.decisions[0].previous_signal_id.is_none());
848    }
849
850    #[test]
851    fn renamed_anchor_carries_a_previous_signal_id_for_review_memory() {
852        // A coordination decision on a file renamed src/old.ts -> src/new.ts. The
853        // signal_id keys on the NEW path; previous_signal_id keys on the OLD path,
854        // so a cloud memory layer carries a prior dismissal across the `git mv`.
855        let d = deltas(&[], &[]);
856        let coordination = vec![CoordinationAnchor {
857            changed_file: "src/new.ts".to_string(),
858            consumed_symbols: vec!["compute".to_string()],
859            consumer_count: 2,
860            line: 0,
861        }];
862        let routing = empty_routing();
863        let rename = |rel: &str| -> Option<String> {
864            (rel == "src/new.ts").then(|| "src/old.ts".to_string())
865        };
866        let surface = extract_decision_surface(&DecisionInputs {
867            deltas: &d,
868            boundary_anchors: &[],
869            coordination: &coordination,
870            public_api_anchor_line: 0,
871            affected_not_shown: 2,
872            routing: &routing,
873            head_source: &no_source,
874            rename_old_path: &rename,
875            internal_consumers: &no_consumers,
876            cap: 4,
877        });
878        assert_eq!(surface.decisions.len(), 1);
879        let decision = &surface.decisions[0];
880        assert_eq!(
881            decision.signal_id,
882            derive_signal_id(DecisionCategory::PublicApiContract, "contract:src/new.ts")
883        );
884        assert_eq!(
885            decision.previous_signal_id,
886            Some(derive_signal_id(
887                DecisionCategory::PublicApiContract,
888                "contract:src/old.ts"
889            ))
890        );
891    }
892
893    #[test]
894    fn signal_id_is_deterministic_and_namespaced_by_category() {
895        let a = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
896        let b = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
897        assert_eq!(a, b, "deterministic");
898        let c = derive_signal_id(DecisionCategory::PublicApiContract, "ui->-db");
899        assert_ne!(a, c, "category namespaces the hash");
900        assert!(a.starts_with("sig:"));
901    }
902
903    #[test]
904    fn consequence_ranks_less_reversible_categories_higher() {
905        // Same blast: dependency > public-api > coupling on reversibility weight.
906        let dep = DecisionCategory::Dependency.reversibility_weight();
907        let api = DecisionCategory::PublicApiContract.reversibility_weight();
908        let coupling = DecisionCategory::CouplingBoundary.reversibility_weight();
909        assert!(dep > api && api > coupling);
910    }
911}