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    append_boundary_decisions(&mut decisions, inputs);
361    append_public_api_decision(&mut decisions, inputs);
362    append_coordination_decisions(&mut decisions, inputs);
363    decisions
364}
365
366fn append_boundary_decisions(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
367    for key in &inputs.deltas.boundary_introduced {
368        let anchor = inputs
369            .boundary_anchors
370            .iter()
371            .find(|a| &a.zone_pair_key == key);
372        let (anchor_file, anchor_line, from_zone, to_zone) = anchor.map_or_else(
373            || (String::new(), 0, key.clone(), String::new()),
374            |a| {
375                (
376                    a.from_file.clone(),
377                    a.line,
378                    a.from_zone.clone(),
379                    a.to_zone.clone(),
380                )
381            },
382        );
383        let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
384        decisions.push(build_decision(
385            DecisionSpec {
386                category: DecisionCategory::CouplingBoundary,
387                candidate_key: key.clone(),
388                question: boundary_question(&from_zone, &to_zone),
389                tradeoff: boundary_tradeoff(&from_zone, &to_zone, internal_consumer_count),
390                anchor_file,
391                anchor_line,
392                blast: inputs.affected_not_shown,
393                internal_consumer_count,
394            },
395            inputs,
396        ));
397    }
398}
399
400fn append_public_api_decision(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
401    if !inputs.deltas.public_api_added.is_empty() {
402        // The candidate key is the full sorted added-key set joined: one stable
403        // id per change, never one-per-symbol (kills the 111-export noise).
404        let key = inputs.deltas.public_api_added.join("|");
405        let anchor_file = inputs
406            .deltas
407            .public_api_added
408            .first()
409            .and_then(|k| k.split("::").next())
410            .map(str::to_string)
411            .unwrap_or_default();
412        let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
413        decisions.push(build_decision(
414            DecisionSpec {
415                category: DecisionCategory::PublicApiContract,
416                candidate_key: key,
417                question: public_api_question(inputs.deltas.public_api_added.len()),
418                tradeoff: public_api_tradeoff(
419                    inputs.deltas.public_api_added.len(),
420                    internal_consumer_count,
421                ),
422                anchor_file,
423                anchor_line: inputs.public_api_anchor_line,
424                blast: inputs.affected_not_shown,
425                internal_consumer_count,
426            },
427            inputs,
428        ));
429    }
430}
431
432fn append_coordination_decisions(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
433    for gap in inputs.coordination {
434        let key = format!("contract:{}", gap.changed_file);
435        decisions.push(build_decision(
436            DecisionSpec {
437                category: DecisionCategory::PublicApiContract,
438                candidate_key: key,
439                question: coordination_question(
440                    &gap.changed_file,
441                    &gap.consumed_symbols,
442                    gap.consumer_count,
443                ),
444                tradeoff: coordination_tradeoff(gap.consumer_count),
445                anchor_file: gap.changed_file.clone(),
446                anchor_line: gap.line,
447                blast: gap.consumer_count,
448                // The coordination arm already carries the honest per-decision
449                // count; no precomputed-map lookup needed.
450                internal_consumer_count: gap.consumer_count,
451            },
452            inputs,
453        ));
454    }
455}
456
457/// Extract the full decision surface from the assembled brief inputs: classify
458/// the SOLID-3 candidates, anchor each `signal_id`, rank by consequence, cap to
459/// the working-memory limit, collapse the rest, and drop suppressed decisions.
460///
461/// The emitted-signal-id allowlist is built over EVERY classified decision
462/// (before the cap and before suppression drops), so `accept_signal_id` still
463/// recognizes a collapsed-or-suppressed decision's anchor as fallow-emitted.
464#[must_use]
465pub fn extract_decision_surface(inputs: &DecisionInputs<'_>) -> DecisionSurface {
466    let cap = inputs.cap.clamp(MIN_DECISION_CAP, MAX_DECISION_CAP);
467
468    let mut classified = classify_candidates(inputs);
469
470    // The allowlist: every signal_id the deterministic layer emitted.
471    let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
472
473    // Drop suppressed decisions (suppression parity): a `// fallow-ignore` on the
474    // anchor hides the decision. Done BEFORE the cap so a suppressed decision does
475    // not consume a slot. The signal_id stays on the allowlist (anchor is still a
476    // real fallow signal), so an agent re-proposing it is not "hallucinating".
477    classified.retain(|d| {
478        let source = (inputs.head_source)(&d.anchor_file);
479        !is_decision_suppressed(source.as_deref(), d.category, d.anchor_line)
480    });
481
482    // Rank by consequence desc; stable, deterministic tiebreak on signal_id.
483    classified.sort_by(|a, b| {
484        b.consequence
485            .cmp(&a.consequence)
486            .then_with(|| a.signal_id.cmp(&b.signal_id))
487    });
488
489    let total = classified.len();
490    let truncated = if total > cap {
491        let collapsed = total - cap;
492        classified.truncate(cap);
493        Some(TruncationNote {
494            collapsed,
495            reason: format!(
496                "{collapsed} more structural decision{} collapsed below the cap of {cap}",
497                if collapsed == 1 { "" } else { "s" }
498            ),
499        })
500    } else {
501        None
502    };
503
504    DecisionSurface {
505        decisions: classified,
506        truncated,
507        emitted_signal_ids,
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use fallow_output::RoutingUnit;
515
516    fn deltas(boundary: &[&str], public_api: &[&str]) -> ReviewDeltas {
517        ReviewDeltas {
518            boundary_introduced: boundary.iter().map(|s| (*s).to_string()).collect(),
519            cycle_introduced: Vec::new(),
520            public_api_added: public_api.iter().map(|s| (*s).to_string()).collect(),
521        }
522    }
523
524    fn no_source(_: &str) -> Option<String> {
525        None
526    }
527
528    fn no_consumers(_: &str) -> u64 {
529        0
530    }
531
532    fn inputs<'a>(
533        deltas: &'a ReviewDeltas,
534        boundary_anchors: &'a [BoundaryAnchor],
535        coordination: &'a [CoordinationAnchor],
536        routing: &'a RoutingFacts,
537        head_source: &'a dyn Fn(&str) -> Option<String>,
538        cap: usize,
539    ) -> DecisionInputs<'a> {
540        DecisionInputs {
541            deltas,
542            boundary_anchors,
543            coordination,
544            public_api_anchor_line: 0,
545            affected_not_shown: 3,
546            routing,
547            head_source,
548            rename_old_path: &no_source,
549            internal_consumers: &no_consumers,
550            cap,
551        }
552    }
553
554    fn empty_routing() -> RoutingFacts {
555        RoutingFacts::default()
556    }
557
558    // (d) None of the four cut categories can ever appear: the enum has exactly
559    // three discriminants, so this is a compile-time + runtime guarantee.
560    #[test]
561    fn only_three_categories_exist_no_cut_category_representable() {
562        let all = [
563            DecisionCategory::CouplingBoundary,
564            DecisionCategory::PublicApiContract,
565            DecisionCategory::Dependency,
566        ];
567        assert_eq!(all.len(), 3);
568        // Serialized tags never include a cut-category name.
569        for c in all {
570            let tag = c.tag();
571            for cut in ["abstraction", "deletion", "convention", "irreversib"] {
572                assert!(!tag.contains(cut), "cut category {cut} leaked into {tag}");
573            }
574        }
575    }
576
577    // (a) Every surfaced decision has a signal_id fallow emitted.
578    #[test]
579    fn every_decision_signal_id_resolves_to_an_emitted_candidate() {
580        let d = deltas(&["ui->-db"], &["src/api.ts::Widget"]);
581        let anchors = vec![BoundaryAnchor {
582            zone_pair_key: "ui->-db".to_string(),
583            from_file: "src/ui/page.ts".to_string(),
584            from_zone: "ui".to_string(),
585            to_zone: "db".to_string(),
586            line: 4,
587        }];
588        let routing = empty_routing();
589        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
590        assert!(!surface.decisions.is_empty());
591        for decision in &surface.decisions {
592            assert!(
593                surface.accept_signal_id(&decision.signal_id),
594                "decision {} has an unanchored signal_id",
595                decision.question
596            );
597        }
598    }
599
600    // (b) An injected decision with no signal anchor is REJECTED.
601    #[test]
602    fn injected_unanchored_signal_id_is_rejected() {
603        let d = deltas(&["ui->-db"], &[]);
604        let anchors = vec![BoundaryAnchor {
605            zone_pair_key: "ui->-db".to_string(),
606            from_file: "src/ui/page.ts".to_string(),
607            from_zone: "ui".to_string(),
608            to_zone: "db".to_string(),
609            line: 1,
610        }];
611        let routing = empty_routing();
612        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
613        // A fabricated id the deterministic layer never emitted.
614        assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
615        assert!(!surface.accept_signal_id("sig:0000000000000000"));
616        // The real one is accepted.
617        let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
618        assert!(surface.accept_signal_id(&real));
619    }
620
621    // (c) A >cap input is capped to 4 plus/minus 1 with a truncation reason.
622    #[test]
623    fn over_cap_input_is_capped_with_truncation_reason() {
624        // 6 boundary edges; default cap 4.
625        let d = deltas(&["a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x"], &[]);
626        let routing = empty_routing();
627        let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
628        assert_eq!(surface.decisions.len(), 4, "capped to default 4");
629        let note = surface.truncated.expect("truncation note present");
630        assert_eq!(note.collapsed, 2);
631        assert!(note.reason.contains("collapsed"));
632        assert!(note.reason.contains('2'));
633    }
634
635    #[test]
636    fn cap_is_clamped_to_the_4_plus_minus_1_band() {
637        let d = deltas(
638            &[
639                "a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x", "g->-x",
640            ],
641            &[],
642        );
643        let routing = empty_routing();
644        // cap=10 clamps to MAX (5).
645        let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
646        assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
647        // cap=1 clamps to MIN (3).
648        let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
649        assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
650    }
651
652    // (e) A `// fallow-ignore` suppresses a flagged decision.
653    #[test]
654    fn fallow_ignore_suppresses_a_flagged_decision() {
655        let d = deltas(&["ui->-db"], &[]);
656        let anchors = vec![BoundaryAnchor {
657            zone_pair_key: "ui->-db".to_string(),
658            from_file: "src/ui/page.ts".to_string(),
659            from_zone: "ui".to_string(),
660            to_zone: "db".to_string(),
661            line: 3,
662        }];
663        let routing = empty_routing();
664
665        // No suppression: one decision surfaces.
666        let unsuppressed =
667            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
668        assert_eq!(unsuppressed.decisions.len(), 1);
669
670        // File-level suppression hides it.
671        let file_src = |f: &str| {
672            (f == "src/ui/page.ts").then(|| {
673                "// fallow-ignore-file decision-surface\nimport db from 'db';\n".to_string()
674            })
675        };
676        let suppressed =
677            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &file_src, 4));
678        assert!(
679            suppressed.decisions.is_empty(),
680            "file-level ignore hides it"
681        );
682        // But the signal id stays on the allowlist (the anchor is still real).
683        let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
684        assert!(suppressed.accept_signal_id(&id));
685
686        // Line-level suppression immediately above the anchor line also hides it.
687        let line_src = |f: &str| {
688            (f == "src/ui/page.ts").then(|| {
689                "line1\n// fallow-ignore-next-line decision-surface\nimport db from 'db';\n"
690                    .to_string()
691            })
692        };
693        let line_suppressed =
694            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &line_src, 4));
695        assert!(
696            line_suppressed.decisions.is_empty(),
697            "line-level ignore hides it"
698        );
699    }
700
701    #[test]
702    fn bare_blanket_ignore_suppresses_without_a_kind() {
703        let d = deltas(&["ui->-db"], &[]);
704        let anchors = vec![BoundaryAnchor {
705            zone_pair_key: "ui->-db".to_string(),
706            from_file: "src/ui/page.ts".to_string(),
707            from_zone: "ui".to_string(),
708            to_zone: "db".to_string(),
709            line: 2,
710        }];
711        let routing = empty_routing();
712        let bare = |f: &str| {
713            (f == "src/ui/page.ts")
714                .then(|| "// fallow-ignore-next-line\nimport db from 'db';\n".to_string())
715        };
716        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &bare, 4));
717        assert!(surface.decisions.is_empty(), "bare blanket ignore hides it");
718    }
719
720    #[test]
721    fn unrelated_kind_ignore_does_not_suppress() {
722        let d = deltas(&["ui->-db"], &[]);
723        let anchors = vec![BoundaryAnchor {
724            zone_pair_key: "ui->-db".to_string(),
725            from_file: "src/ui/page.ts".to_string(),
726            from_zone: "ui".to_string(),
727            to_zone: "db".to_string(),
728            line: 2,
729        }];
730        let routing = empty_routing();
731        let other = |f: &str| {
732            (f == "src/ui/page.ts").then(|| {
733                "// fallow-ignore-next-line unused-export\nimport db from 'db';\n".to_string()
734            })
735        };
736        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &other, 4));
737        assert_eq!(
738            surface.decisions.len(),
739            1,
740            "an ignore naming a different kind must not suppress a decision"
741        );
742    }
743
744    #[test]
745    fn routed_expert_is_paired_with_a_decision() {
746        let d = deltas(&["ui->-db"], &[]);
747        let anchors = vec![BoundaryAnchor {
748            zone_pair_key: "ui->-db".to_string(),
749            from_file: "src/ui/page.ts".to_string(),
750            from_zone: "ui".to_string(),
751            to_zone: "db".to_string(),
752            line: 1,
753        }];
754        let routing = RoutingFacts {
755            units: vec![RoutingUnit {
756                file: "src/ui/page.ts".to_string(),
757                expert: vec!["@team/ui".to_string()],
758                bus_factor_one: true,
759            }],
760        };
761        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
762        assert_eq!(surface.decisions.len(), 1);
763        assert_eq!(surface.decisions[0].expert, vec!["@team/ui".to_string()]);
764        assert!(surface.decisions[0].bus_factor_one);
765    }
766
767    #[test]
768    fn public_api_is_batch_consolidated_to_one_decision_r1() {
769        // 111 added export keys collapse to ONE public-API decision (R1).
770        let keys: Vec<String> = (0..111).map(|i| format!("src/ui/index.ts::C{i}")).collect();
771        let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
772        let d = deltas(&[], &key_refs);
773        let routing = empty_routing();
774        let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
775        let public_api_count = surface
776            .decisions
777            .iter()
778            .filter(|dec| dec.category == DecisionCategory::PublicApiContract)
779            .count();
780        assert_eq!(
781            public_api_count, 1,
782            "R1: one public-API decision per change"
783        );
784        assert!(surface.decisions[0].question.contains("111"));
785    }
786
787    #[test]
788    fn public_api_decision_carries_honest_consumer_count_and_tradeoff() {
789        // A public-API delta whose anchor has 7 in-repo out-of-diff consumers must
790        // surface that honest number on the decision AND name it as a fact in the
791        // trade-off clause, distinct from the project-wide ranking proxy (`blast`).
792        let d = deltas(&[], &["src/ui/index.ts::Widget"]);
793        let routing = empty_routing();
794        let seven = |_: &str| 7u64;
795        let surface = extract_decision_surface(&DecisionInputs {
796            deltas: &d,
797            boundary_anchors: &[],
798            coordination: &[],
799            public_api_anchor_line: 0,
800            // The project-wide proxy must NOT become the display number.
801            affected_not_shown: 99,
802            routing: &routing,
803            head_source: &no_source,
804            rename_old_path: &no_source,
805            internal_consumers: &seven,
806            cap: 4,
807        });
808        let dec = surface
809            .decisions
810            .iter()
811            .find(|dec| dec.category == DecisionCategory::PublicApiContract)
812            .expect("a public-API decision");
813        assert_eq!(dec.internal_consumer_count, 7, "honest per-anchor count");
814        assert_ne!(
815            dec.internal_consumer_count, dec.blast,
816            "display number must stay distinct from the ranking proxy"
817        );
818        assert!(
819            dec.tradeoff.contains("7 in-repo"),
820            "trade-off clause states the count as a fact: {}",
821            dec.tradeoff
822        );
823        assert!(
824            dec.question.ends_with('?'),
825            "the decision stays a question (taste ownership)"
826        );
827    }
828
829    #[test]
830    fn coordination_gap_becomes_a_public_api_contract_decision() {
831        let d = deltas(&[], &[]);
832        let coordination = vec![CoordinationAnchor {
833            changed_file: "src/core.ts".to_string(),
834            consumed_symbols: vec!["compute".to_string()],
835            consumer_count: 4,
836            line: 7,
837        }];
838        let routing = empty_routing();
839        let surface =
840            extract_decision_surface(&inputs(&d, &[], &coordination, &routing, &no_source, 4));
841        assert_eq!(surface.decisions.len(), 1);
842        assert_eq!(
843            surface.decisions[0].category,
844            DecisionCategory::PublicApiContract
845        );
846        assert_eq!(surface.decisions[0].blast, 4);
847        // The contract symbol's declaration line flows onto the decision so a PR
848        // review can anchor an inline comment to the exact export.
849        assert_eq!(surface.decisions[0].anchor_line, 7);
850        // No rename in this change -> no previous_signal_id (the default).
851        assert!(surface.decisions[0].previous_signal_id.is_none());
852    }
853
854    #[test]
855    fn renamed_anchor_carries_a_previous_signal_id_for_review_memory() {
856        // A coordination decision on a file renamed src/old.ts -> src/new.ts. The
857        // signal_id keys on the NEW path; previous_signal_id keys on the OLD path,
858        // so a cloud memory layer carries a prior dismissal across the `git mv`.
859        let d = deltas(&[], &[]);
860        let coordination = vec![CoordinationAnchor {
861            changed_file: "src/new.ts".to_string(),
862            consumed_symbols: vec!["compute".to_string()],
863            consumer_count: 2,
864            line: 0,
865        }];
866        let routing = empty_routing();
867        let rename = |rel: &str| -> Option<String> {
868            (rel == "src/new.ts").then(|| "src/old.ts".to_string())
869        };
870        let surface = extract_decision_surface(&DecisionInputs {
871            deltas: &d,
872            boundary_anchors: &[],
873            coordination: &coordination,
874            public_api_anchor_line: 0,
875            affected_not_shown: 2,
876            routing: &routing,
877            head_source: &no_source,
878            rename_old_path: &rename,
879            internal_consumers: &no_consumers,
880            cap: 4,
881        });
882        assert_eq!(surface.decisions.len(), 1);
883        let decision = &surface.decisions[0];
884        assert_eq!(
885            decision.signal_id,
886            derive_signal_id(DecisionCategory::PublicApiContract, "contract:src/new.ts")
887        );
888        assert_eq!(
889            decision.previous_signal_id,
890            Some(derive_signal_id(
891                DecisionCategory::PublicApiContract,
892                "contract:src/old.ts"
893            ))
894        );
895    }
896
897    #[test]
898    fn signal_id_is_deterministic_and_namespaced_by_category() {
899        let a = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
900        let b = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
901        assert_eq!(a, b, "deterministic");
902        let c = derive_signal_id(DecisionCategory::PublicApiContract, "ui->-db");
903        assert_ne!(a, c, "category namespaces the hash");
904        assert!(a.starts_with("sig:"));
905    }
906
907    #[test]
908    fn consequence_ranks_less_reversible_categories_higher() {
909        // Same blast: dependency > public-api > coupling on reversibility weight.
910        let dep = DecisionCategory::Dependency.reversibility_weight();
911        let api = DecisionCategory::PublicApiContract.reversibility_weight();
912        let coupling = DecisionCategory::CouplingBoundary.reversibility_weight();
913        assert!(dep > api && api > coupling);
914    }
915}