Skip to main content

scc_graph/
clustering.rs

1//! Semantic hierarchical clustering (generalization wave).
2//!
3//! Files belong to architecture because of BEHAVIOR, not directories. The
4//! clustering graph is built over **regions** — the finest evidence-backed
5//! file groups. Authoritative boundaries (declared / package / deployment /
6//! cli dirs) are EVIDENCE AND CONSTRAINTS, not indivisible atoms: the
7//! region hierarchy starts one level below them — sub-directories holding
8//! files become regions, and a flat boundary dir's direct files split per
9//! file (one region per module, so a flat library package like click can
10//! split into core/parser/termui-style components). Code-region top dirs
11//! split the same way (sub-dirs + one dir region for direct files), and
12//! the synthetic `root` stays atomic. Weighted edges from graph evidence
13//! drive a greedy agglomerative merge:
14//!
15//! | signal | weight |
16//! |---|---|
17//! | semantic calls | +2 |
18//! | shared state authority | +4 |
19//! | public surface cohesion (exports under one interface / extension point) | +4 |
20//! | flow participation | +3 |
21//! | event ownership (shared topic) | +3 |
22//! | invocation-surface cohesion (same framework family) | +2 |
23//! | type hierarchy (shared base) | +2 |
24//! | co-change | +1 (capped at 5) |
25//! | deployment containment | +5 (constraint, not decoration) |
26//! | package containment (same package's subregions) | +5 (deployment-style cohesion, NOT a hard atom) |
27//! | archetype prior (CLI/Framework/Service/Compiler) | +2 |
28//!
29//! The merge may SPLIT a top-level directory or a package into several
30//! components (its sub-regions land in different clusters when intra-dir
31//! cohesion is low) and may MERGE regions across directories when behavior
32//! says so — the longest-prefix path assignment is replaced by the
33//! clustering result. Path/package/deployment remain as constraints and
34//! priors: package membership is a +5 cohesion signal between a package's
35//! subregions (a pair still needs further evidence to reach
36//! `MERGE_THRESHOLD`), and merging ACROSS deployment units requires weight
37//! > [`SERVICE_THRESHOLD`].
38//!
39//! Layers: region → component (greedy merge ≥ `MERGE_THRESHOLD` with
40//! cohesion-aware acceptance) → service (pass-2 sum-linkage ≥
41//! `SERVICE_THRESHOLD`). The merge is not pure max-linkage: the strongest
42//! pairwise edge selects the candidate, but the union is accepted only when
43//! the average linkage across ALL cross pairs is at least
44//! [`COHESION_FRACTION`] of the max edge (or the edge is absolute-strong,
45//! ≥ [`SERVICE_THRESHOLD`]) — a lone strong edge can no longer drag two
46//! clusters together (single-link chaining). Determinism is the contract:
47//! every iteration is over sorted collections and ties break on the
48//! smallest index pair.
49
50use crate::{RealityGraph, Result};
51use scc_core::{kinds, predicates, Archetype};
52use scc_store::Store;
53use serde_json::json;
54use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
55
56use crate::components::{
57    boundary_rank, component_for_path, BOUNDARY_CODE_REGION, BOUNDARY_PACKAGE, BOUNDARY_ROOT,
58    ComponentCandidate, LAYER_CODE_REGION, LAYER_COMPONENT, LAYER_SERVICE, MERGE_THRESHOLD,
59    SERVICE_THRESHOLD,
60};
61
62// ---- signal weights (the clustering graph edge list) ----
63/// Semantic calls between regions (+2).
64pub const W_CALL: i32 = 2;
65/// Shared state authority: same store written or same config read (+4).
66pub const W_STATE: i32 = 4;
67/// Public surface cohesion: exports consumed by the same facade/interface,
68/// or symbols registered into the same extension point (+4).
69pub const W_PUBLIC_SURFACE: i32 = 4;
70/// LibrarySdk archetype: public API cohesion weight doubles (+8).
71pub const W_PUBLIC_SURFACE_LIBRARY: i32 = 8;
72/// Flow participation: regions whose symbols share a flow walk (+3).
73pub const W_FLOW: i32 = 3;
74/// Event ownership: regions sharing a topic via PUBLISHES/SUBSCRIBES/
75/// CONSUMES (+3).
76pub const W_EVENT: i32 = 3;
77/// Invocation-surface cohesion: regions whose symbols expose invocation
78/// surfaces of the same framework family (queue consumers, schedulers,
79/// plugin registrations; framework_callback+lifecycle count as one
80/// family) (+2, capped per pair).
81pub const W_SURFACE_FAMILY: i32 = 2;
82/// Type hierarchy: regions implementing/inheriting the same base (+2).
83pub const W_TYPE_HIERARCHY: i32 = 2;
84/// Deployment boundary containment: same deployment unit (+5).
85pub const W_DEPLOYMENT: i32 = 5;
86/// Package containment: subregions of the same package cohere (+5 — the
87/// same value as the deployment signal; package membership is a
88/// deployment-style cohesion prior, not a hard atom).
89pub const W_PACKAGE: i32 = 5;
90/// Co-change cap: a change-heavy region pair never dominates the graph.
91pub const COCHANGE_CAP: i32 = 5;
92
93/// Cohesion-aware merge acceptance (single-link chaining guard): a
94/// candidate pair — the strongest pairwise edge — merges only when the
95/// average linkage over ALL cross pairs is at least this fraction of the
96/// max edge, unless the edge is absolute-strong (≥ [`SERVICE_THRESHOLD`]).
97pub const COHESION_FRACTION: f64 = 0.4;
98
99/// Post-merge size cap: a cluster WITHOUT declared intent (no authoritative
100/// boundary evidence) covering more than this many files is split by
101/// top-level directory. Mega-clusters glue unrelated subsystems into one
102/// component (django's 39-dir soup, uutils' 7-dir chain) — their impact
103/// answers name everything and therefore nothing. The cap is generic (file
104/// count, not repo names) and intent always wins: declared architecture is
105/// never re-split. Receipt: django's intent-less mega-component held 39
106/// dirs; vitest's 10-dir chain; uutils' 7-dir chain — all unactionable.
107// trace:exempt reason=const-data
108pub const MAX_UNDECLARED_FILES: usize = 64;
109
110/// One clustered component: the merge result over a set of atomic regions.
111#[derive(Debug, Clone)]
112pub struct ClusterComponent {
113    pub name: String,
114    /// Union of the member regions' path prefixes (sorted, deduped).
115    pub dirs: Vec<String>,
116    /// Highest-authority boundary kind among the members.
117    pub boundary_kind: String,
118    /// `LAYER_*` constant: `code_region` for a single bare dir region,
119    /// `component` for anything with evidence (authoritative boundary or a
120    /// multi-region merge).
121    pub layer: String,
122    /// File entity ids owned by the cluster (sorted).
123    pub files: Vec<String>,
124    /// Member region indices (sorted).
125    pub member_regions: Vec<usize>,
126}
127
128/// The structural output of the clusterer: what `compile_components` turns
129/// into component entities. All maps are name/order aligned and sorted.
130pub struct ClusteringResult {
131    /// Clusters sorted by name (the order component entities are built in).
132    pub clusters: Vec<ClusterComponent>,
133    /// The pruned atomic regions (authoritative boundaries + code-region
134    /// dirs/subdirs), aligned with each cluster's `member_regions` indices.
135    pub regions: Vec<ComponentCandidate>,
136    /// symbol id -> component NAME (the cluster the symbol's region merged
137    /// into).
138    pub symbol_component: HashMap<String, String>,
139    /// component name -> file entity ids.
140    pub files_in_component: BTreeMap<String, Vec<String>>,
141    /// component name -> deployment unit name (tightest build context).
142    pub parent_per_comp: BTreeMap<String, String>,
143    /// Cross-component weight sums (aligned with `clusters`) for the
144    /// pass-2 service merge.
145    pub component_weights: Vec<Vec<i32>>,
146    /// Cross-component deployment-unit constraint (aligned with
147    /// `clusters`): true when both sides sit in DIFFERENT deployment
148    /// units (such pairs merge only at weight > SERVICE_THRESHOLD).
149    pub cross_unit: Vec<Vec<bool>>,
150}
151
152/// Build the region hierarchy from the path candidates:
153///
154/// 1. authoritative boundaries (declared / package / deployment / cli) are
155///    EVIDENCE, not indivisible atoms: the region hierarchy starts one
156///    level below the boundary dir — each immediate sub-directory that
157///    holds files becomes a region, and a boundary dir's direct files
158///    split per file when there are several (one region per module, so a
159///    flat library package can split into per-module components). A
160///    boundary dir with a single direct file keeps the boundary-named
161///    region (there is nothing to split). Every split region carries the
162///    parent's evidence class. The synthetic `root` (and any boundary
163///    candidate rooted at `root`) stays atomic — root-level files always
164///    map to the root region.
165/// 2. each code-region top-level dir becomes one region per immediate
166///    sub-directory that holds files, plus one region for the dir's own
167///    direct files;
168/// 3. the synthetic `root` region always exists (root-level files).
169///
170/// Regions that end up with zero assigned files are pruned afterwards by
171/// the caller (a code-region dir whose every file belongs to an
172/// authoritative boundary is not a region). Deterministic: candidates are
173/// processed in sorted name order, dirs in sorted order, and every file
174/// list is sorted.
175// trace:v1 id=impl.scc.clustering work=WORK-SCC-005 satisfies=REQ-SCC-IR
176pub fn build_regions(
177    graph: &RealityGraph,
178    candidates: &[ComponentCandidate],
179) -> Vec<ComponentCandidate> {
180    let mut regions: Vec<ComponentCandidate> = Vec::new();
181    let mut by_name: HashSet<String> = HashSet::new();
182    let push = |regions: &mut Vec<ComponentCandidate>,
183                by_name: &mut HashSet<String>,
184                c: ComponentCandidate| {
185        if by_name.insert(c.name.clone()) {
186            regions.push(c);
187        }
188    };
189
190    // test files (files referenced by TEST entities): verification code.
191    // A test file never forms its own component — when its package dir
192    // splits per-file, it rides along with a module region (the module it
193    // verifies), and its calls/flows stay excluded from clustering.
194    let mut test_files: HashSet<String> = HashSet::new();
195    for t in graph.entities_of_kind(kinds::TEST) {
196        if let Some(f) = t.attributes.get("file").and_then(|v| v.as_str()) {
197            test_files.insert(f.to_string());
198        }
199    }
200
201    // 1. authoritative boundaries, split one level below (sorted names).
202    //    PACKAGE dirs are the architecture leaf — a flat package's direct
203    //    modules ARE its sub-architecture, so they split per file (one
204    //    region per module); declared/deployment/cli dirs are coarser
205    //    containers: their direct files keep one dir-named region (the
206    //    sub-directories still split).
207    let mut auth: Vec<ComponentCandidate> = candidates
208        .iter()
209        .filter(|c| c.boundary_kind != BOUNDARY_CODE_REGION)
210        .cloned()
211        .collect();
212    auth.sort_by(|a, b| a.name.cmp(&b.name));
213    for c in &auth {
214        if c.boundary_kind == BOUNDARY_ROOT || c.name == "root" {
215            // the root fallback stays one region (root-level files)
216            push(&mut regions, &mut by_name, c.clone());
217            continue;
218        }
219        let is_package = c.boundary_kind == BOUNDARY_PACKAGE;
220        let mut dirs = c.dirs.clone();
221        dirs.sort();
222        dirs.dedup();
223        // dirs of this candidate holding exactly one direct file keep the
224        // boundary-named region (a package shell with a single module)
225        let mut dir_region_dirs: Vec<String> = Vec::new();
226        for dir in &dirs {
227            let dir = dir.trim_end_matches('/');
228            if dir.is_empty() {
229                continue;
230            }
231            let prefix = format!("{dir}/");
232            let mut subs: BTreeSet<String> = BTreeSet::new();
233            let mut direct: Vec<String> = Vec::new();
234            for f in graph.entities_of_kind(kinds::FILE) {
235                if f.name == *dir || f.name.starts_with(&prefix) {
236                    let rest = &f.name[dir.len() + 1..];
237                    if let Some(slash) = rest.find('/') {
238                        subs.insert(format!("{dir}/{}", &rest[..slash]));
239                    } else {
240                        direct.push(f.name.clone());
241                    }
242                }
243            }
244            direct.sort();
245            for sub in subs {
246                push(&mut regions, &mut by_name, ComponentCandidate {
247                    name: sub.clone(),
248                    dirs: vec![sub.clone()],
249                    boundary_kind: c.boundary_kind.clone(),
250                    intent: c.intent.clone(),
251                });
252            }
253            if is_package && direct.len() >= 2 {
254                // flat package: the direct modules ARE the sub-architecture
255                // — one region per non-test file; test files ride along
256                // with the first module region (deterministic: sorted)
257                let code: Vec<&String> = direct
258                    .iter()
259                    .filter(|f| !test_files.contains(*f))
260                    .collect();
261                if code.len() >= 2 {
262                    for (k, f) in code.iter().enumerate() {
263                        let mut dirs = vec![(*f).clone()];
264                        if k == 0 {
265                            for t in direct.iter().filter(|t| test_files.contains(*t)) {
266                                dirs.push(t.clone());
267                            }
268                        }
269                        push(&mut regions, &mut by_name, ComponentCandidate {
270                            name: (**f).clone(),
271                            dirs,
272                            boundary_kind: c.boundary_kind.clone(),
273                            intent: c.intent.clone(),
274                        });
275                    }
276                } else {
277                    dir_region_dirs.push(dir.to_string());
278                }
279            } else if !direct.is_empty() {
280                // a boundary dir with a single direct file (any kind), or a
281                // non-package boundary dir with several direct files, keeps
282                // the boundary-named region for its direct files
283                dir_region_dirs.push(dir.to_string());
284            }
285        }
286        if !dir_region_dirs.is_empty() {
287            push(&mut regions, &mut by_name, ComponentCandidate {
288                name: c.name.clone(),
289                dirs: dir_region_dirs,
290                boundary_kind: c.boundary_kind.clone(),
291                intent: c.intent.clone(),
292            });
293        }
294    }
295
296    // 2. code-region top-level dirs: direct files -> one dir region;
297    //    sub-directories with files -> one region each
298    let mut code: Vec<ComponentCandidate> = candidates
299        .iter()
300        .filter(|c| c.boundary_kind == BOUNDARY_CODE_REGION)
301        .cloned()
302        .collect();
303    code.sort_by(|a, b| a.name.cmp(&b.name));
304    for c in &code {
305        if c.name == "root" || by_name.contains(&c.name) {
306            continue;
307        }
308        let dir = &c.name;
309        let prefix = format!("{dir}/");
310        let mut direct = false;
311        let mut subs: BTreeSet<String> = BTreeSet::new();
312        for f in graph.entities_of_kind(kinds::FILE) {
313            if f.name == *dir || f.name.starts_with(&prefix) {
314                let rest = &f.name[dir.len() + 1..];
315                if let Some(slash) = rest.find('/') {
316                    subs.insert(format!("{dir}/{}", &rest[..slash]));
317                } else {
318                    direct = true;
319                }
320            }
321        }
322        if direct && !by_name.contains(dir) {
323            by_name.insert(dir.clone());
324            regions.push(ComponentCandidate {
325                name: dir.clone(),
326                dirs: vec![dir.clone()],
327                boundary_kind: BOUNDARY_CODE_REGION.to_string(), intent: None,
328            });
329        }
330        for sub in subs {
331            if by_name.contains(&sub) {
332                continue;
333            }
334            by_name.insert(sub.clone());
335            regions.push(ComponentCandidate {
336                name: sub.clone(),
337                dirs: vec![sub.clone()],
338                boundary_kind: BOUNDARY_CODE_REGION.to_string(), intent: None,
339            });
340        }
341    }
342
343    // 3. root region always exists (root-level files; empty repos too)
344    if !by_name.contains("root") {
345        regions.push(ComponentCandidate {
346            name: "root".to_string(),
347            dirs: vec!["root".to_string()],
348            boundary_kind: BOUNDARY_ROOT.to_string(), intent: None,
349        });
350    }
351    regions
352}
353
354/// Cluster the atomic regions into components (and record the pass-2
355/// service weights). See the module docs for the signal list.
356// trace:v1 id=impl.scc.clustering.merge work=WORK-SCC-005 satisfies=REQ-SCC-IR
357// trace:v1 id=impl.scc.clustering.sizecap work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
358pub fn cluster_components(
359    graph: &RealityGraph,
360    store: &Store,
361    intent: &[(String, serde_json::Value)],
362    candidates: &[ComponentCandidate],
363    pairs: &[crate::cochange::CochangePair],
364    du_ctxs: &[(String, String)],
365) -> Result<ClusteringResult> {
366    let mut regions = build_regions(graph, candidates);
367
368    // ---- file -> region assignment (longest prefix over region dirs) ----
369    let mut files_in_region: BTreeMap<String, Vec<String>> = BTreeMap::new();
370    for f in graph.entities_of_kind(kinds::FILE) {
371        let region = component_for_path(&f.name, &regions);
372        files_in_region
373            .entry(region)
374            .or_default()
375            .push(f.id.clone());
376    }
377    for v in files_in_region.values_mut() {
378        v.sort();
379    }
380    // prune code-region regions with zero assigned files (their files were
381    // captured by an authoritative boundary — no region shell remains)
382    regions.retain(|r| {
383        r.boundary_kind != BOUNDARY_CODE_REGION
384            || files_in_region
385                .get(&r.name)
386                .map(|v| !v.is_empty())
387                .unwrap_or(false)
388    });
389
390    let n = regions.len();
391    let idx: HashMap<&str, usize> = regions
392        .iter()
393        .enumerate()
394        .map(|(i, r)| (r.name.as_str(), i))
395        .collect();
396    let mut w: Vec<Vec<i32>> = vec![vec![0i32; n]; n];
397
398    // ---- symbol -> region ----
399    let mut symbol_region: HashMap<String, usize> = HashMap::new();
400    for (region, files) in &files_in_region {
401        for fid in files {
402            for r in graph.out_pred(fid, scc_core::predicates::CONTAINS) {
403                if let Some(&ri) = idx.get(region.as_str()) {
404                    symbol_region.insert(r.object.clone(), ri);
405                }
406            }
407        }
408    }
409    // file path -> region (co-change pairs reference paths, not ids)
410    let mut path_region: BTreeMap<String, usize> = BTreeMap::new();
411    for f in graph.entities_of_kind(kinds::FILE) {
412        if let Some(&ri) = idx.get(component_for_path(&f.name, &regions).as_str()) {
413            path_region.insert(f.name.clone(), ri);
414        }
415    }
416    let mut syms: Vec<(&String, usize)> = symbol_region
417        .iter()
418        .map(|(s, r)| (s, *r))
419        .collect();
420    syms.sort();
421
422    // ---- deployment unit per region (tightest build context) ----
423    let mut parent_per_region: Vec<Option<String>> = vec![None; n];
424    for (i, r) in regions.iter().enumerate() {
425        for (du_name, ctx) in du_ctxs {
426            let inside = r.dirs.iter().any(|d| {
427                let d = d.trim_end_matches('/');
428                d == ctx.as_str() || d.starts_with(&format!("{ctx}/"))
429            });
430            if inside {
431                parent_per_region[i] = Some(du_name.clone());
432                break;
433            }
434        }
435    }
436
437    // test files (files referenced by TEST entities): verification code.
438    // Its coupling to the code under test — calls, flows — is expected and
439    // must NOT drive architecture; a test suite never merges into the
440    // component it verifies.
441    let mut test_files: HashSet<String> = HashSet::new();
442    for t in graph.entities_of_kind(kinds::TEST) {
443        if let Some(f) = t.attributes.get("file").and_then(|v| v.as_str()) {
444            test_files.insert(f.to_string());
445        }
446    }
447    let is_test_symbol = |sym: &str| -> bool {
448        graph
449            .entities
450            .get(sym)
451            .and_then(|e| e.attributes.get("file"))
452            .and_then(|v| v.as_str())
453            .map(|f| test_files.contains(f))
454            .unwrap_or(false)
455    };
456
457    let archetype = crate::archetype::detect_archetype(graph, store);
458
459    // ---- signal (a): semantic calls (+2) ----
460    for (sym, ra) in &syms {
461        if is_test_symbol(sym) {
462            continue;
463        }
464        for r in graph.out_pred(sym, scc_core::predicates::CALLS) {
465            if let Some(&rb) = symbol_region.get(&r.object) {
466                if ra != &rb {
467                    w[*ra][rb] += W_CALL;
468                    w[rb][*ra] += W_CALL;
469                }
470            }
471        }
472    }
473
474    // ---- signal (b): shared state authority (+4) ----
475    for group in crate::state::state_authority_groups(graph) {
476        let set: BTreeSet<String> = group
477            .iter()
478            .filter_map(|s| symbol_region.get(s).map(|&r| regions[r].name.clone()))
479            .collect();
480        if set.len() >= 2 {
481            add_pair_weight(&mut w, &idx, &set, W_STATE);
482        }
483    }
484
485    // ---- signals (c) public surface cohesion (+4/+8) and (d) type
486    // hierarchy (+2): shared IMPLEMENTS/INHERITS targets and shared
487    // extension-point (REGISTERS) targets ----
488    let mut public_targets: HashSet<String> = HashSet::new();
489    for e in graph.entities_of_kind(kinds::EXPORT) {
490        public_targets.insert(e.id.clone());
491    }
492    for r in graph.all_rels() {
493        if r.predicate == predicates::EXPORTS {
494            // both the export entity AND the exporting symbol are public
495            // API — an IMPLEMENTS edge into an exported symbol is an
496            // interface implementation
497            public_targets.insert(r.object.clone());
498            public_targets.insert(r.subject.clone());
499        }
500    }
501    let mut hier_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
502    let mut reg_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
503    for &(sym, ra) in &syms {
504        for pred in [predicates::IMPLEMENTS, predicates::INHERITS] {
505            for r in graph.out_pred(sym, pred) {
506                let group = hier_groups.entry(r.object.clone()).or_default();
507                group.insert(regions[ra].name.clone());
508                // the facade/interface's own home region coheres with its
509                // implementors (exports consumed by the same interface)
510                if let Some(&rt) = symbol_region.get(&r.object) {
511                    group.insert(regions[rt].name.clone());
512                }
513            }
514        }
515        for r in graph.out_pred(sym, predicates::REGISTERS) {
516            let group = reg_groups.entry(r.object.clone()).or_default();
517            group.insert(regions[ra].name.clone());
518            // the extension point's own home region coheres with its
519            // registrants
520            if let Some(&rt) = symbol_region.get(&r.object) {
521                group.insert(regions[rt].name.clone());
522            }
523        }
524    }
525    let ps_w = if archetype == Archetype::LibrarySdk {
526        W_PUBLIC_SURFACE_LIBRARY
527    } else {
528        W_PUBLIC_SURFACE
529    };
530    for (target, set) in &hier_groups {
531        if set.len() < 2 {
532            continue;
533        }
534        if public_targets.contains(target) {
535            // public API cohesion: exports under one exported interface
536            add_pair_weight(&mut w, &idx, set, ps_w);
537        } else {
538            // plain shared base class
539            add_pair_weight(&mut w, &idx, set, W_TYPE_HIERARCHY);
540        }
541    }
542    for set in reg_groups.values() {
543        if set.len() >= 2 {
544            // extension points: symbols registered into the same
545            // plugin/extension/interface registry
546            add_pair_weight(&mut w, &idx, set, ps_w);
547        }
548    }
549
550    // ---- signal (e): flow participation (+3, capped per region pair) ----
551    // "Regions whose symbols share a flow cohere": a categorical signal —
552    // one shared flow chain (possibly seeded by several entrypoints, e.g.
553    // a route and its exported handler) counts once, never stacked.
554    // Verification-seeded flows are excluded (see `test_files` above).
555    let mut flow_pairs: BTreeSet<(usize, usize)> = BTreeSet::new();
556    for (entry, group) in crate::flows::flow_participant_groups(graph, store, intent) {
557        if is_test_symbol(&entry) {
558            continue;
559        }
560        let set: BTreeSet<String> = group
561            .iter()
562            .filter_map(|s| symbol_region.get(s).map(|&r| regions[r].name.clone()))
563            .collect();
564        if set.len() < 2 {
565            continue;
566        }
567        let cs: Vec<&String> = set.iter().collect();
568        for (k, a) in cs.iter().enumerate() {
569            let Some(&ia) = idx.get(a.as_str()) else { continue };
570            for b in cs.iter().skip(k + 1) {
571                let Some(&ib) = idx.get(b.as_str()) else { continue };
572                flow_pairs.insert(if ia < ib { (ia, ib) } else { (ib, ia) });
573            }
574        }
575    }
576    for (ia, ib) in flow_pairs {
577        w[ia][ib] += W_FLOW;
578        w[ib][ia] += W_FLOW;
579    }
580
581    // ---- signal (f): event ownership (+3, same topic) ----
582    let mut topic_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
583    for &(sym, ra) in &syms {
584        for pred in [
585            predicates::PUBLISHES,
586            predicates::CONSUMES,
587            predicates::SUBSCRIBES,
588        ] {
589            for r in graph.out_pred(sym, pred) {
590                topic_groups
591                    .entry(r.object.clone())
592                    .or_default()
593                    .insert(regions[ra].name.clone());
594            }
595        }
596    }
597    for set in topic_groups.values() {
598        if set.len() >= 2 {
599            add_pair_weight(&mut w, &idx, set, W_EVENT);
600        }
601    }
602
603    // ---- signal (g): invocation-surface cohesion (+2, capped per pair) ----
604    // Regions whose symbols own invocation surfaces of the SAME framework
605    // family cohere: queue consumers with queue consumers, schedulers with
606    // schedulers, plugin registrations with plugin registrations;
607    // framework_callback and lifecycle symbols count as one family. The
608    // family group is a set of region names, so a shared family adds +2
609    // once per pair — never stacked per surface.
610    let mut surface_families: BTreeMap<&'static str, BTreeSet<String>> = BTreeMap::new();
611    for s in crate::flows::invocation_surfaces(graph) {
612        if is_test_symbol(&s.symbol) {
613            continue;
614        }
615        let family: &'static str = match s.kind {
616            scc_core::InvocationSurfaceKind::Queue => "queue",
617            scc_core::InvocationSurfaceKind::Schedule => "schedule",
618            scc_core::InvocationSurfaceKind::Plugin => "plugin",
619            scc_core::InvocationSurfaceKind::FrameworkCallback
620            | scc_core::InvocationSurfaceKind::Lifecycle => "lifecycle",
621            _ => continue,
622        };
623        let Some(&ra) = symbol_region.get(&s.symbol) else { continue };
624        surface_families
625            .entry(family)
626            .or_default()
627            .insert(regions[ra].name.clone());
628    }
629    for set in surface_families.values() {
630        if set.len() >= 2 {
631            add_pair_weight(&mut w, &idx, set, W_SURFACE_FAMILY);
632        }
633    }
634
635    // ---- signal (h): co-change (+1 per spanning pair, capped) ----
636    let mut cc: BTreeMap<(usize, usize), i32> = BTreeMap::new();
637    for p in pairs {
638        if let (Some(&ra), Some(&rb)) = (path_region.get(&p.a), path_region.get(&p.b)) {
639            if ra != rb {
640                let (x, y) = if ra < rb { (ra, rb) } else { (rb, ra) };
641                *cc.entry((x, y)).or_insert(0) += 1;
642            }
643        }
644    }
645    for ((x, y), count) in cc {
646        let weight = count.min(COCHANGE_CAP);
647        w[x][y] += weight;
648        w[y][x] += weight;
649    }
650
651    // ---- signal (i): deployment boundary containment (+5) ----
652    let mut du_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
653    for (i, du) in parent_per_region.iter().enumerate() {
654        if let Some(du) = du {
655            du_groups
656                .entry(du.clone())
657                .or_default()
658                .insert(regions[i].name.clone());
659        }
660    }
661    for set in du_groups.values() {
662        if set.len() >= 2 {
663            add_pair_weight(&mut w, &idx, set, W_DEPLOYMENT);
664        }
665    }
666
667    // ---- signal (i2): package containment (+5, same package) ----
668    // Package membership is a deployment-style cohesion prior, NOT a hard
669    // atom: subregions of the same package cohere (+5 per pair), but a
670    // pair still needs further evidence to reach MERGE_THRESHOLD — so a
671    // flat package with unrelated modules SPLITS into separate components,
672    // while modules that call/flow together merge back into the package.
673    let mut pkg_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
674    for (i, r) in regions.iter().enumerate() {
675        for cand in candidates {
676            if cand.boundary_kind != BOUNDARY_PACKAGE {
677                continue;
678            }
679            let inside = cand.dirs.iter().any(|cd| {
680                let cd = cd.trim_end_matches('/');
681                r.dirs.iter().any(|d| {
682                    let d = d.trim_end_matches('/');
683                    d == cd || d.starts_with(&format!("{cd}/"))
684                })
685            });
686            if inside {
687                pkg_groups
688                    .entry(cand.name.clone())
689                    .or_default()
690                    .insert(regions[i].name.clone());
691            }
692        }
693    }
694    for set in pkg_groups.values() {
695        if set.len() >= 2 {
696            add_pair_weight(&mut w, &idx, set, W_PACKAGE);
697        }
698    }
699
700    // ---- signal (j): archetype emphasis (+2, one region trait) ----
701    if let Some(prior) = crate::archetype::cluster_prior(archetype) {
702        let mut set: BTreeSet<String> = BTreeSet::new();
703        for &(sym, ra) in &syms {
704            let Some(e) = graph.entities.get(sym) else { continue };
705            let name = regions[ra].name.clone();
706            let has = match prior {
707                crate::archetype::ClusterPrior::CliCommands => {
708                    let cli_ep = e
709                        .attributes
710                        .get("entrypoints")
711                        .and_then(|v| v.as_array())
712                        .map(|eps| {
713                            eps.iter().any(|k| {
714                                matches!(k.as_str(), Some("cli-subcommand") | Some("cli"))
715                            })
716                        })
717                        .unwrap_or(false);
718                    let cli_flags = e
719                        .attributes
720                        .get("cli_flags")
721                        .and_then(|v| v.as_array())
722                        .map(|fl| !fl.is_empty())
723                        .unwrap_or(false);
724                    cli_ep || cli_flags
725                }
726                crate::archetype::ClusterPrior::FrameworkRegistrations => {
727                    !graph.out_pred(sym, predicates::REGISTERS).is_empty()
728                }
729                crate::archetype::ClusterPrior::ServiceEntrypoints => {
730                    !graph.out_pred(sym, predicates::HANDLES).is_empty()
731                        || e.attributes
732                            .get("entrypoints")
733                            .and_then(|v| v.as_array())
734                            .map(|eps| !eps.is_empty())
735                            .unwrap_or(false)
736                }
737                crate::archetype::ClusterPrior::CompilerPhases => {
738                    crate::archetype::is_phase_symbol(&e.name)
739                }
740            };
741            if has {
742                set.insert(name);
743            }
744        }
745        if set.len() >= 2 {
746            add_pair_weight(&mut w, &idx, &set, crate::archetype::PRIOR_WEIGHT);
747        }
748    }
749
750    // ---- cross-unit constraint: pairs in DIFFERENT deployment units may
751    // only merge at weight > SERVICE_THRESHOLD ----
752    let mut cross_unit: Vec<Vec<bool>> = vec![vec![false; n]; n];
753    for i in 0..n {
754        for j in (i + 1)..n {
755            if let (Some(a), Some(b)) = (&parent_per_region[i], &parent_per_region[j]) {
756                if a != b {
757                    cross_unit[i][j] = true;
758                    cross_unit[j][i] = true;
759                }
760            }
761        }
762    }
763
764    // ---- pass 1: greedy merge at MERGE_THRESHOLD (max-linkage) ----
765    let mut dsu = Dsu::new(n);
766    greedy_merge(&mut dsu, &w, n, MERGE_THRESHOLD, &cross_unit);
767    let mut clusters: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
768    for i in 0..n {
769        clusters.entry(dsu.find(i)).or_default().push(i);
770    }
771    let mut comps: Vec<ClusterComponent> = clusters
772        .values()
773        .map(|members| build_cluster(&regions, members))
774        .collect();
775    comps.sort_by(|a, b| a.name.cmp(&b.name));
776
777    // ---- file/symbol maps over the clusters ----
778    let mut files_in_component: BTreeMap<String, Vec<String>> = BTreeMap::new();
779    for c in &comps {
780        let mut files: Vec<String> = Vec::new();
781        for &m in &c.member_regions {
782            if let Some(fs) = files_in_region.get(&regions[m].name) {
783                files.extend(fs.iter().cloned());
784            }
785        }
786        files.sort();
787        files.dedup();
788        files_in_component.insert(c.name.clone(), files);
789    }
790    let mut symbol_component: HashMap<String, String> = HashMap::new();
791    for (sym, &ri) in &symbol_region {
792        let root = dsu.find(ri);
793        if let Some(members) = clusters.get(&root) {
794            let comp_name = &comps
795                .iter()
796                .find(|c| c.member_regions == *members)
797                .map(|c| c.name.clone())
798                .unwrap_or_default();
799            symbol_component.insert(sym.clone(), comp_name.clone());
800        }
801    }
802
803    // ---- post-merge size cap (generic, intent-preserving): clusters with
804    // no declared intent, no service/deployment/package boundary, covering
805    // more than MAX_UNDECLARED_FILES files are split by top-level directory.
806    // Greedy merge only grows; without this, one high-weight chain glues a
807    // monorepo into a component whose impact answers name everything and
808    // therefore nothing (django 39-dir soup). Splits are named
809    // `<cluster>/<topdir>`, deterministic. Service-boundary clusters (the
810    // microservices-demo moat) never split: deployment/package evidence
811    // always protects them.
812    {
813        let mut split_comps: Vec<ClusterComponent> = Vec::new();
814        let mut split_files: BTreeMap<String, Vec<String>> = BTreeMap::new();
815        for c in &comps {
816            let files = files_in_component.get(&c.name).cloned().unwrap_or_default();
817            let protected = c.member_regions.iter().any(|&m| {
818                regions[m].intent.is_some()
819                    || regions[m].boundary_kind == crate::components::BOUNDARY_DECLARED
820                    || regions[m].boundary_kind == crate::components::BOUNDARY_DEPLOYMENT
821                    || regions[m].boundary_kind == crate::components::BOUNDARY_PACKAGE
822            });
823            if protected || files.len() <= MAX_UNDECLARED_FILES {
824                split_comps.push(c.clone());
825                split_files.insert(c.name.clone(), files);
826                continue;
827            }
828            // Recursive deepening: group member regions by path segment at
829            // increasing depth until every piece fits the cap or no further
830            // subdivision exists. One level is not enough — django/ alone
831            // holds ~1700 files, so top-dir splitting just renames the soup.
832            // Pieces keep their OWN natural cluster names (longest common
833            // prefix of their members) — inheriting the parent's 39-dir
834            // chain made every piece truncate identically in display.
835            // FIFO queue keeps emission deterministic.
836            let mut queue: VecDeque<(Vec<usize>, usize, Vec<String>)> =
837                VecDeque::new();
838            queue.push_back((c.member_regions.clone(), 0, Vec::new()));
839            let mut emitted_any = false;
840            while let Some((members, depth, trail)) = queue.pop_front() {
841                let mut sfiles: Vec<String> = Vec::new();
842                for &m in &members {
843                    if let Some(fs) = files_in_region.get(&regions[m].name) {
844                        sfiles.extend(fs.iter().cloned());
845                    }
846                }
847                sfiles.sort();
848                sfiles.dedup();
849                if sfiles.len() <= MAX_UNDECLARED_FILES {
850                    let mut sub = build_cluster(&regions, &members);
851                    sub.name = dedup_cluster_name(&split_files, &sub.name, &trail);
852                    split_files.insert(sub.name.clone(), sfiles);
853                    split_comps.push(sub);
854                    emitted_any = true;
855                    continue;
856                }
857                let mut by_seg: BTreeMap<String, Vec<usize>> = BTreeMap::new();
858                for &m in &members {
859                    let seg = regions[m]
860                        .name
861                        .split('/')
862                        .nth(depth)
863                        .unwrap_or("root")
864                        .to_string();
865                    by_seg.entry(seg).or_default().push(m);
866                }
867                if by_seg.len() < 2 {
868                    // Cannot subdivide further: keep as-is even over cap.
869                    let mut sub = build_cluster(&regions, &members);
870                    sub.name = dedup_cluster_name(&split_files, &sub.name, &trail);
871                    split_files.insert(sub.name.clone(), sfiles);
872                    split_comps.push(sub);
873                    emitted_any = true;
874                    continue;
875                }
876                for (seg, sub_members) in &by_seg {
877                    let mut t = trail.clone();
878                    t.push(seg.clone());
879                    queue.push_back((sub_members.clone(), depth + 1, t));
880                }
881            }
882            if !emitted_any {
883                split_comps.push(c.clone());
884                split_files.insert(c.name.clone(), files);
885            }
886            continue;
887        }
888        split_comps.sort_by(|a, b| a.name.cmp(&b.name));
889        comps = split_comps;
890        files_in_component = split_files;
891        // Remap symbols through the split: a symbol's region belongs to
892        // exactly one post-split piece (region membership is disjoint).
893        let region_owner: std::collections::HashMap<usize, String> = comps
894            .iter()
895            .flat_map(|c| c.member_regions.iter().map(|&m| (m, c.name.clone())))
896            .collect();
897        for (sym, comp) in symbol_component.iter_mut() {
898            if comps.iter().any(|c| &c.name == comp) {
899                continue;
900            }
901            if let Some(&ri) = symbol_region.get(sym) {
902                if let Some(owner) = region_owner.get(&ri) {
903                    *comp = owner.clone();
904                }
905            }
906        }
907    }
908
909    // ---- parent per component (deployment unit of the cluster dirs) ----
910    let mut parent_per_comp: BTreeMap<String, String> = BTreeMap::new();
911    for c in &comps {
912        for (du_name, ctx) in du_ctxs {
913            let inside = c.dirs.iter().any(|d| {
914                let d = d.trim_end_matches('/');
915                d == ctx.as_str() || d.starts_with(&format!("{ctx}/"))
916            });
917            if inside {
918                parent_per_comp.insert(c.name.clone(), du_name.clone());
919                break;
920            }
921        }
922    }
923
924    // ---- pass-2 weights: sum-linkage over the component clusters ----
925    let m = comps.len();
926    let mut component_weights: Vec<Vec<i32>> = vec![vec![0i32; m]; m];
927    let mut cunit: Vec<Vec<bool>> = vec![vec![false; m]; m];
928    for (i, ci) in comps.iter().enumerate() {
929        for (j, cj) in comps.iter().enumerate() {
930            if i == j {
931                continue;
932            }
933            let mut sum = 0;
934            for &ra in &ci.member_regions {
935                for &rb in &cj.member_regions {
936                    sum += w[ra][rb];
937                }
938            }
939            component_weights[i][j] = sum;
940        }
941    }
942    for (i, ci) in comps.iter().enumerate() {
943        for (j, cj) in comps.iter().enumerate() {
944            if i == j {
945                continue;
946            }
947            let cross = ci.member_regions.iter().any(|&ra| {
948                cj.member_regions.iter().any(|&rb| cross_unit[ra][rb])
949            });
950            cunit[i][j] = cross;
951        }
952    }
953
954    Ok(ClusteringResult {
955        clusters: comps,
956        regions,
957        symbol_component,
958        files_in_component,
959        parent_per_comp,
960        component_weights,
961        cross_unit: cunit,
962    })
963}
964
965/// Build one cluster record from its member region indices: name (longest
966/// common dir prefix, or sorted names joined with `+` when the regions
967/// share no directory), highest-authority boundary kind, layer, and the
968/// union of member dirs. Deterministic.
969fn build_cluster(regions: &[ComponentCandidate], members: &[usize]) -> ClusterComponent {
970    let name = cluster_name(regions, members);
971    let mut dirs: BTreeSet<String> = BTreeSet::new();
972    let mut boundary: Option<(u8, String)> = None;
973    // deterministic boundary pick: members in sorted-name order, highest
974    // authority wins, ties keep the first (lowest name)
975    let mut sorted: Vec<usize> = members.to_vec();
976    sorted.sort_by(|a, b| regions[*a].name.cmp(&regions[*b].name));
977    for &m in &sorted {
978        let r = &regions[m];
979        for d in &r.dirs {
980            dirs.insert(d.clone());
981        }
982        let rank = boundary_rank(&r.boundary_kind);
983        match &boundary {
984            Some((br, _)) if *br >= rank => {}
985            _ => boundary = Some((rank, r.boundary_kind.clone())),
986        }
987    }
988    let boundary_kind = boundary.map(|(_, k)| k).unwrap_or_else(|| BOUNDARY_CODE_REGION.to_string());
989    let layer = if members.len() == 1
990        && (boundary_kind == BOUNDARY_CODE_REGION || boundary_kind == BOUNDARY_ROOT)
991    {
992        LAYER_CODE_REGION.to_string()
993    } else {
994        LAYER_COMPONENT.to_string()
995    };
996    let mut member_regions = members.to_vec();
997    member_regions.sort();
998    ClusterComponent {
999        name,
1000        dirs: dirs.into_iter().collect(),
1001        boundary_kind,
1002        layer,
1003        files: Vec::new(),
1004        member_regions,
1005    }
1006}
1007
1008/// Disambiguate a post-split cluster name against already-emitted pieces:
1009/// natural name wins when free, else the subdivision trail, else a counter.
1010/// All inputs sorted/deterministic, so the result is stable run-to-run.
1011// trace:exempt reason=internal-detail
1012fn dedup_cluster_name(
1013    taken: &BTreeMap<String, Vec<String>>,
1014    base: &str,
1015    trail: &[String],
1016) -> String {
1017    if !taken.contains_key(base) {
1018        return base.to_string();
1019    }
1020    if !trail.is_empty() {
1021        let t = format!("{}/{}", base, trail.join("/"));
1022        if !taken.contains_key(&t) {
1023            return t;
1024        }
1025    }
1026    let mut n = 2;
1027    while taken.contains_key(&format!("{base}~{n}")) {
1028        n += 1;
1029    }
1030    format!("{base}~{n}")
1031}
1032
1033/// Deterministic cluster name: the longest common directory prefix of the
1034/// member region names when they share one (and it is not another region's
1035/// name — that would collide), otherwise the sorted member names joined
1036/// with `+`.
1037// trace:exempt reason=internal-detail
1038fn cluster_name(regions: &[ComponentCandidate], members: &[usize]) -> String {
1039    // declared intent names architecture: when every member region
1040    // descends from the same declared component, the cluster keeps the
1041    // declared name (the clusterer decided membership; intent names it).
1042    let intents: BTreeSet<&str> = members
1043        .iter()
1044        .filter_map(|&i| regions[i].intent.as_deref())
1045        .collect();
1046    if intents.len() == 1 {
1047        return intents.into_iter().next().unwrap().to_string();
1048    }
1049    if members.len() == 1 {
1050        return regions[members[0]].name.clone();
1051    }
1052    let segs: Vec<Vec<&str>> = members
1053        .iter()
1054        .map(|&i| regions[i].name.split('/').collect())
1055        .collect();
1056    let mut common: Vec<&str> = Vec::new();
1057    'outer: for k in 0..segs[0].len() {
1058        let seg = segs[0][k];
1059        for other in segs.iter().skip(1) {
1060            if other.get(k) != Some(&seg) {
1061                break 'outer;
1062            }
1063        }
1064        common.push(seg);
1065    }
1066    if !common.is_empty() {
1067        let lcp = common.join("/");
1068        // the LCP must not be a *different* region's name (id collision)
1069        let taken_by_other = regions.iter().enumerate().any(|(i, r)| {
1070            !members.contains(&i) && r.name == lcp
1071        });
1072        if !taken_by_other {
1073            return lcp;
1074        }
1075    }
1076    let mut names: Vec<String> = members.iter().map(|&i| regions[i].name.clone()).collect();
1077    names.sort();
1078    names.join("+")
1079}
1080
1081/// Add `weight` to every pair inside `set` (deterministic: sorted names).
1082fn add_pair_weight(w: &mut [Vec<i32>], idx: &HashMap<&str, usize>, set: &BTreeSet<String>, weight: i32) {
1083    let cs: Vec<&String> = set.iter().collect();
1084    for (k, a) in cs.iter().enumerate() {
1085        let Some(&ia) = idx.get(a.as_str()) else { continue };
1086        for b in cs.iter().skip(k + 1) {
1087            let Some(&ib) = idx.get(b.as_str()) else { continue };
1088            w[ia][ib] += weight;
1089            w[ib][ia] += weight;
1090        }
1091    }
1092}
1093
1094/// Disjoint-set union-find with path compression.
1095// trace:exempt reason=internal-helper
1096pub(crate) struct Dsu {
1097    parent: Vec<usize>,
1098}
1099
1100impl Dsu {
1101    pub(crate) fn new(n: usize) -> Dsu {
1102        Dsu {
1103            parent: (0..n).collect(),
1104        }
1105    }
1106    pub(crate) fn find(&mut self, mut x: usize) -> usize {
1107        let mut root = x;
1108        while self.parent[root] != root {
1109            root = self.parent[root];
1110        }
1111        while self.parent[x] != root {
1112            let next = self.parent[x];
1113            self.parent[x] = root;
1114            x = next;
1115        }
1116        root
1117    }
1118    pub(crate) fn union(&mut self, a: usize, b: usize) {
1119        let (ra, rb) = (self.find(a), self.find(b));
1120        if ra != rb {
1121            self.parent[rb] = ra;
1122        }
1123    }
1124}
1125
1126/// Max-linkage weight between two DSU clusters. Pairs that cross deployment
1127/// units contribute their raw weight only when it exceeds
1128/// `SERVICE_THRESHOLD` (the ">12" cross-unit rule); otherwise they count 0.
1129fn cluster_weight(w: &[Vec<i32>], dsu: &mut Dsu, a: usize, b: usize, cross_unit: &[Vec<bool>]) -> i32 {
1130    let (ra, rb) = (dsu.find(a), dsu.find(b));
1131    let mut m = 0;
1132    for (i, row) in w.iter().enumerate() {
1133        if dsu.find(i) != ra {
1134            continue;
1135        }
1136        for (j, cell) in row.iter().enumerate() {
1137            if dsu.find(j) != rb {
1138                continue;
1139            }
1140            let cell = if cross_unit[i][j] && *cell <= SERVICE_THRESHOLD {
1141                0
1142            } else {
1143                *cell
1144            };
1145            m = m.max(cell);
1146        }
1147    }
1148    m
1149}
1150
1151/// Average-linkage weight between two DSU clusters: mean effective weight
1152/// over ALL cross region pairs (zero-weight pairs included) — the
1153/// cohesion guard against single-link chaining. Effective weights apply
1154/// the cross-unit rule (pairs ≤ `SERVICE_THRESHOLD` across deployment
1155/// units count 0).
1156// trace:exempt reason=internal-helper
1157fn cluster_avg(w: &[Vec<i32>], dsu: &mut Dsu, a: usize, b: usize, cross_unit: &[Vec<bool>]) -> f64 {
1158    let (ra, rb) = (dsu.find(a), dsu.find(b));
1159    let mut sum = 0i64;
1160    let mut count = 0i64;
1161    for (i, row) in w.iter().enumerate() {
1162        if dsu.find(i) != ra {
1163            continue;
1164        }
1165        for (j, cell) in row.iter().enumerate() {
1166            if dsu.find(j) != rb {
1167                continue;
1168            }
1169            let cell = if cross_unit[i][j] && *cell <= SERVICE_THRESHOLD {
1170                0
1171            } else {
1172                *cell
1173            };
1174            sum += cell as i64;
1175            count += 1;
1176        }
1177    }
1178    if count == 0 {
1179        return 0.0;
1180    }
1181    sum as f64 / count as f64
1182}
1183
1184/// Greedy merge: repeatedly union the highest-weight candidate pair whose
1185/// merge passes the cohesion acceptance. Candidate selection is still the
1186/// strongest pairwise edge (max-linkage, [`cluster_weight`]), but the
1187/// union is accepted only when the average linkage across ALL cross pairs
1188/// is at least [`COHESION_FRACTION`] of the max edge, OR the edge is
1189/// absolute-strong (≥ [`SERVICE_THRESHOLD`]) — a lone strong edge can no
1190/// longer drag two clusters together (single-link chaining). Pairs that
1191/// cross deployment units only count their weight when it exceeds
1192/// `SERVICE_THRESHOLD`. Deterministic: on weight ties the smallest (i, j)
1193/// wins.
1194// trace:exempt reason=internal-helper
1195fn greedy_merge(
1196    dsu: &mut Dsu,
1197    w: &[Vec<i32>],
1198    n: usize,
1199    threshold: i32,
1200    cross_unit: &[Vec<bool>],
1201) {
1202    loop {
1203        let mut best: Option<(i32, usize, usize)> = None;
1204        for i in 0..n {
1205            for j in (i + 1)..n {
1206                if dsu.find(i) == dsu.find(j) {
1207                    continue;
1208                }
1209                let wi = cluster_weight(w, dsu, i, j, cross_unit);
1210                if wi < threshold {
1211                    continue;
1212                }
1213                let accepted = wi >= SERVICE_THRESHOLD
1214                    || cluster_avg(w, dsu, i, j, cross_unit) >= COHESION_FRACTION * wi as f64;
1215                if !accepted {
1216                    continue;
1217                }
1218                match best {
1219                    Some((bw, bi, bj)) => {
1220                        if wi > bw || (wi == bw && (i < bi || (i == bi && j < bj))) {
1221                            best = Some((wi, i, j));
1222                        }
1223                    }
1224                    None => best = Some((wi, i, j)),
1225                }
1226            }
1227        }
1228        match best {
1229            Some((_, i, j)) => dsu.union(i, j),
1230            _ => break,
1231        }
1232    }
1233}
1234
1235/// Pass-2 (service) merge: sum-linkage. Pass 1's max-linkage already
1236/// absorbed every pair >= MERGE_THRESHOLD, so a component pair can only
1237/// reach SERVICE_THRESHOLD by *accumulated* cross evidence (e.g. four weak
1238/// signals of 3 each) — the "merged again at >= 12" step. The cross-unit
1239/// constraint still applies per region pair.
1240fn cluster_weight_sum(
1241    w: &[Vec<i32>],
1242    dsu: &mut Dsu,
1243    a: usize,
1244    b: usize,
1245    cross_unit: &[Vec<bool>],
1246) -> i32 {
1247    let (ra, rb) = (dsu.find(a), dsu.find(b));
1248    let mut sum = 0;
1249    for (i, row) in w.iter().enumerate() {
1250        if dsu.find(i) != ra {
1251            continue;
1252        }
1253        for (j, cell) in row.iter().enumerate() {
1254            if dsu.find(j) != rb {
1255                continue;
1256            }
1257            if cross_unit[i][j] && *cell <= SERVICE_THRESHOLD {
1258                continue;
1259            }
1260            sum += *cell;
1261        }
1262    }
1263    sum
1264}
1265
1266/// Greedy sum-linkage merge (see [`cluster_weight_sum`]).
1267fn greedy_merge_sum(
1268    dsu: &mut Dsu,
1269    w: &[Vec<i32>],
1270    n: usize,
1271    threshold: i32,
1272    cross_unit: &[Vec<bool>],
1273) {
1274    loop {
1275        let mut best: Option<(i32, usize, usize)> = None;
1276        for i in 0..n {
1277            for j in (i + 1)..n {
1278                if dsu.find(i) == dsu.find(j) {
1279                    continue;
1280                }
1281                let wi = cluster_weight_sum(w, dsu, i, j, cross_unit);
1282                match best {
1283                    Some((bw, bi, bj)) => {
1284                        if wi > bw || (wi == bw && (i < bi || (i == bi && j < bj))) {
1285                            best = Some((wi, i, j));
1286                        }
1287                    }
1288                    None => best = Some((wi, i, j)),
1289                }
1290            }
1291        }
1292        match best {
1293            Some((wi, i, j)) if wi >= threshold => dsu.union(i, j),
1294            _ => break,
1295        }
1296    }
1297}
1298
1299/// Relationship-id prefix for hierarchy CONTAINS edges (kept distinct from
1300/// the component rel prefix so the two clears never cross).
1301const HIER_RELPREFIX: &str = "rel:hier:";
1302
1303fn hier_rel(parts: &[&str]) -> String {
1304    let mut h = blake3::Hasher::new();
1305    for p in parts {
1306        h.update(p.as_bytes());
1307        h.update(b"|");
1308    }
1309    format!("{HIER_RELPREFIX}{}", &h.finalize().to_hex()[..12])
1310}
1311
1312/// Pass-2 service compilation: components (pass-1 clusters) merge again by
1313/// sum-linkage at SERVICE_THRESHOLD into SERVICE containers. The flat
1314/// component list is untouched — services are extra entities of kind
1315/// SERVICE with CONTAINS edges to their member component ids. Idempotent:
1316/// stale SERVICE/SUBSYSTEM entities and hierarchy rels are cleared first.
1317pub fn compile_services(
1318    store: &Store,
1319    comps: &[scc_core::Entity],
1320    component_weights: &[Vec<i32>],
1321    cross_unit: &[Vec<bool>],
1322    parent_per_comp: &BTreeMap<String, String>,
1323) -> Result<()> {
1324    clear_hierarchy(store)?;
1325    let n = comps.len();
1326    if n < 2 {
1327        return Ok(());
1328    }
1329    let mut dsu = Dsu::new(n);
1330    greedy_merge_sum(&mut dsu, component_weights, n, SERVICE_THRESHOLD, cross_unit);
1331
1332    let mut unions: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1333    for i in 0..n {
1334        unions.entry(dsu.find(i)).or_default().push(i);
1335    }
1336    let repo = &store.repo_id;
1337    for members in unions.values() {
1338        if members.len() < 2 {
1339            continue;
1340        }
1341        // shared deployment unit -> the unit's name; else sorted names
1342        let dus: BTreeSet<&String> = members
1343            .iter()
1344            .filter_map(|i| parent_per_comp.get(comps[*i].name.as_str()))
1345            .collect();
1346        let name = if dus.len() == 1 {
1347            dus.into_iter().next().unwrap().clone()
1348        } else {
1349            let mut names: Vec<String> =
1350                members.iter().map(|i| comps[*i].name.clone()).collect();
1351            names.sort();
1352            names.join("+")
1353        };
1354        let id = scc_core::entity_id(repo, kinds::SERVICE, &name);
1355        let mut e = scc_core::Entity::new(id.clone(), kinds::SERVICE, name.clone());
1356        e.attr("layer", json!(LAYER_SERVICE));
1357        let member_names: Vec<String> =
1358            members.iter().map(|i| comps[*i].name.clone()).collect();
1359        e.attr("members", json!(member_names));
1360        store.insert_entity(&e, &[])?;
1361        for m in members {
1362            let target = scc_core::entity_id(repo, kinds::COMPONENT, &comps[*m].name);
1363            let r = scc_core::Relationship::new(
1364                hier_rel(&["hier_contains", &id, &target]),
1365                id.clone(),
1366                scc_core::predicates::CONTAINS,
1367                target,
1368                scc_core::Provenance::Extracted,
1369            );
1370            store.insert_relationship(&r, "")?;
1371        }
1372    }
1373    Ok(())
1374}
1375
1376/// Delete stale SUBSYSTEM/SERVICE entities and hierarchy CONTAINS edges so
1377/// a recompile never accumulates containers.
1378pub(crate) fn clear_hierarchy(store: &Store) -> Result<()> {
1379    for kind in [kinds::SUBSYSTEM, kinds::SERVICE] {
1380        let ids: Vec<String> = store
1381            .entities_by_kind(kind)?
1382            .into_iter()
1383            .map(|e| e.id)
1384            .collect();
1385        if !ids.is_empty() {
1386            store.delete_entities(&ids)?;
1387        }
1388    }
1389    let rows = store.all_relationships()?;
1390    let ids: Vec<String> = rows
1391        .into_iter()
1392        .filter(|r| r.id.starts_with(HIER_RELPREFIX))
1393        .map(|r| r.id)
1394        .collect();
1395    for id in ids {
1396        store.delete_relationship(&id)?;
1397    }
1398    Ok(())
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use super::*;
1404    use scc_core::{entity_id, kinds, predicates, symbol_id, Entity, Provenance, Relationship};
1405    use scc_store::Store;
1406
1407    /// Insert a FILE entity plus CONTAINS edges to its symbols; returns the
1408    /// symbol ids. Mirrors the components.rs test helper.
1409    fn insert_file_with_symbols(
1410        store: &Store,
1411        path: &str,
1412        symbols: &[&str],
1413    ) -> Vec<String> {
1414        let repo = store.repo_id.clone();
1415        let file_id = entity_id(&repo, kinds::FILE, path);
1416        store
1417            .insert_entity(&Entity::new(file_id.clone(), kinds::FILE, path), &[path.into()])
1418            .unwrap();
1419        let mut sym_ids = Vec::new();
1420        for s in symbols {
1421            let sid = symbol_id(&repo, path, s);
1422            store
1423                .insert_entity(&Entity::new(sid.clone(), kinds::SYMBOL, *s), &[path.into()])
1424                .unwrap();
1425            store
1426                .insert_relationship(
1427                    &Relationship::new(
1428                        format!("rel:contains:{}:{s}", path.replace('/', "_")),
1429                        file_id.clone(),
1430                        predicates::CONTAINS,
1431                        sid.clone(),
1432                        Provenance::Extracted,
1433                    ),
1434                    path,
1435                )
1436                .unwrap();
1437            sym_ids.push(sid);
1438        }
1439        sym_ids
1440    }
1441
1442    fn store_for() -> (Store, tempfile::TempDir) {
1443        let tmp = tempfile::TempDir::new().unwrap();
1444        let root = tmp.path().join("repo");
1445        std::fs::create_dir_all(&root).unwrap();
1446        let store = Store::open(&tmp.path().join("scc.db"), &root).unwrap();
1447        (store, tmp)
1448    }
1449
1450    fn compile(store: &Store) -> Vec<Entity> {
1451        let graph = RealityGraph::load(store).unwrap();
1452        crate::components::compile_components(&graph, store, &[], &[]).unwrap()
1453    }
1454
1455    #[test]
1456    // trace:exempt reason=unit-test
1457    fn flat_library_package_splits_into_unrelated_modules() {
1458        // Wave 13: a FLAT library package (workspace member whose direct
1459        // files are its modules — no subdirectories) is no longer an
1460        // indivisible atom. The region hierarchy starts one level below
1461        // the package dir: each direct module becomes its own region, and
1462        // package membership is only a +5 cohesion signal — below
1463        // MERGE_THRESHOLD — so modules with NO cross evidence stay split.
1464        let (store, _t) = store_for();
1465        let repo = store.repo_id.clone();
1466        let mut pkg = Entity::new(entity_id(&repo, kinds::PACKAGE, "src"), kinds::PACKAGE, "src");
1467        pkg.attr("path", serde_json::json!("src"));
1468        store
1469            .insert_entity(&pkg, &["src/core.py".into()])
1470            .unwrap();
1471        // three modules, NO cross calls / state / exports — the only
1472        // evidence between them is package containment (+5 < 6)
1473        let _ = insert_file_with_symbols(&store, "src/core.py", &["core_fn"]);
1474        let _ = insert_file_with_symbols(&store, "src/parser.py", &["parse_arg"]);
1475        let _ = insert_file_with_symbols(&store, "src/termui.py", &["render_line"]);
1476
1477        let comps = compile(&store);
1478        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1479        for n in ["src/core.py", "src/parser.py", "src/termui.py"] {
1480            assert!(names.contains(&n), "module {n} split out: {names:?}");
1481        }
1482        assert!(
1483            !names.contains(&"src"),
1484            "no fused package blob: {names:?}"
1485        );
1486        // the split modules stay evidence-backed package components
1487        for n in ["src/core.py", "src/parser.py", "src/termui.py"] {
1488            let c = comps.iter().find(|c| c.name == n).unwrap();
1489            assert_eq!(
1490                c.attributes["boundary_kind"],
1491                serde_json::json!(crate::components::BOUNDARY_PACKAGE),
1492                "{n}"
1493            );
1494            assert_eq!(c.attributes["layer"], serde_json::json!(LAYER_COMPONENT), "{n}");
1495        }
1496    }
1497
1498    #[test]
1499    // trace:exempt reason=unit-test
1500    fn single_link_chaining_is_blocked() {
1501        // Wave 13: four regions A-B-C-D with ONE strong edge (call+state
1502        // = 6) between consecutive regions only. Pure max-linkage would
1503        // collapse the whole chain into one component; the cohesion-aware
1504        // acceptance (avg >= 0.4 * max over ALL cross pairs) stops the
1505        // chain at the third link: {A,B,C} vs {D} has avg 6/3 = 2 < 2.4.
1506        let (store, _t) = store_for();
1507        let repo = store.repo_id.clone();
1508        let sa = insert_file_with_symbols(&store, "a/x.py", &["a_run"]);
1509        let sb = insert_file_with_symbols(&store, "b/x.py", &["b_run"]);
1510        let sc = insert_file_with_symbols(&store, "c/x.py", &["c_run"]);
1511        let sd = insert_file_with_symbols(&store, "d/x.py", &["d_run"]);
1512        // consecutive shared stores: a-b -> db1, b-c -> db2, c-d -> db3
1513        for (i, (syms, db)) in [
1514            ([sa[0].clone(), sb[0].clone()].to_vec(), "db1"),
1515            ([sb[0].clone(), sc[0].clone()].to_vec(), "db2"),
1516            ([sc[0].clone(), sd[0].clone()].to_vec(), "db3"),
1517        ]
1518        .into_iter()
1519        .enumerate()
1520        {
1521            let store_ent = entity_id(&repo, kinds::DATA_STORE, db);
1522            store
1523                .insert_entity(
1524                    &Entity::new(store_ent.clone(), kinds::DATA_STORE, db),
1525                    &["a/x.py".into()],
1526                )
1527                .unwrap();
1528            for (k, sym) in syms.iter().enumerate() {
1529                store
1530                    .insert_relationship(
1531                        &Relationship::new(
1532                            format!("rel:w:{i}:{k}"),
1533                            sym.clone(),
1534                            predicates::WRITES,
1535                            store_ent.clone(),
1536                            Provenance::Extracted,
1537                        ),
1538                        "a/x.py",
1539                    )
1540                    .unwrap();
1541            }
1542        }
1543        // one call per consecutive pair: each edge = 4 (state) + 2 (call)
1544        for (i, (from, to)) in [
1545            (sa[0].clone(), sb[0].clone()),
1546            (sb[0].clone(), sc[0].clone()),
1547            (sc[0].clone(), sd[0].clone()),
1548        ]
1549        .iter()
1550        .enumerate()
1551        {
1552            store
1553                .insert_relationship(
1554                    &Relationship::new(
1555                        format!("rel:call:{i}"),
1556                        from.clone(),
1557                        predicates::CALLS,
1558                        to.clone(),
1559                        Provenance::Extracted,
1560                    ),
1561                    "a/x.py",
1562                )
1563                .unwrap();
1564        }
1565
1566        let comps = compile(&store);
1567        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1568        assert!(
1569            names.contains(&"a+b+c"),
1570            "the first three links cohere (avg 3 >= 2.4): {names:?}"
1571        );
1572        assert!(
1573            names.contains(&"d"),
1574            "the tail link must NOT chain on a single edge: {names:?}"
1575        );
1576        assert!(
1577            !names.contains(&"a+b+c+d"),
1578            "the chain must not collapse into one component: {names:?}"
1579        );
1580    }
1581
1582    #[test]
1583    fn code_region_dir_splits_into_low_cohesion_modules() {
1584        // Two modules in the SAME top-level directory with no behavioral
1585        // evidence between them: the clusterer must SPLIT the dir into two
1586        // components (the longest-prefix assignment would have fused them
1587        // into one `src` blob).
1588        let (store, _t) = store_for();
1589        let repo = store.repo_id.clone();
1590        let _ = insert_file_with_symbols(&store, "src/checkout/cart.py", &["add_item"]);
1591        let _ = insert_file_with_symbols(&store, "src/pricing/tax.py", &["compute_tax"]);
1592        // no calls, no shared state, no shared exports — zero cross weight
1593        let _ = repo;
1594
1595        let comps = compile(&store);
1596        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1597        assert!(
1598            names.contains(&"src/checkout"),
1599            "checkout module split out: {names:?}"
1600        );
1601        assert!(names.contains(&"src/pricing"), "pricing module split out: {names:?}");
1602        assert!(!names.contains(&"src"), "no fused src blob: {names:?}");
1603        assert!(names.contains(&"root"), "root shell stays: {names:?}");
1604        // both modules carry the code-region boundary (bare dirs)
1605        for n in ["src/checkout", "src/pricing"] {
1606            let c = comps.iter().find(|c| c.name == n).unwrap();
1607            assert_eq!(
1608                c.attributes["boundary_kind"],
1609                serde_json::json!(crate::components::BOUNDARY_CODE_REGION),
1610                "{n}"
1611            );
1612            assert_eq!(c.attributes["layer"], serde_json::json!(LAYER_CODE_REGION), "{n}");
1613        }
1614    }
1615
1616    #[test]
1617    fn cross_dir_regions_merge_on_call_and_state_weight() {
1618        // Two modules in DIFFERENT directories with a call (+2) and shared
1619        // store writes (+4) = 6 >= MERGE_THRESHOLD: one component spanning
1620        // both dirs — behavior beats directory.
1621        let (store, _t) = store_for();
1622        let repo = store.repo_id.clone();
1623        let sa = insert_file_with_symbols(&store, "auth/session.py", &["create_session"]);
1624        let sb = insert_file_with_symbols(&store, "users/api.py", &["get_user"]);
1625
1626        let db = entity_id(&repo, kinds::DATA_STORE, "db");
1627        store
1628            .insert_entity(&Entity::new(db.clone(), kinds::DATA_STORE, "db"), &["auth/session.py".into()])
1629            .unwrap();
1630        for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1631            store
1632                .insert_relationship(
1633                    &Relationship::new(
1634                        format!("rel:w:{i}"),
1635                        sym.clone(),
1636                        predicates::WRITES,
1637                        db.clone(),
1638                        Provenance::Extracted,
1639                    ),
1640                    "auth/session.py",
1641                )
1642                .unwrap();
1643        }
1644        // create_session -> get_user (cross-dir semantic call)
1645        store
1646            .insert_relationship(
1647                &Relationship::new(
1648                    "rel:call",
1649                    sa[0].clone(),
1650                    predicates::CALLS,
1651                    sb[0].clone(),
1652                    Provenance::Extracted,
1653                ),
1654                "auth/session.py",
1655            )
1656            .unwrap();
1657
1658        let comps = compile(&store);
1659        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1660        assert!(
1661            names.contains(&"auth+users"),
1662            "cross-dir merge into one component: {names:?}"
1663        );
1664        assert!(!names.contains(&"auth"), "no auth shell: {names:?}");
1665        assert!(!names.contains(&"users"), "no users shell: {names:?}");
1666        let merged = comps.iter().find(|c| c.name == "auth+users").unwrap();
1667        let paths = merged.attributes["implementation"]["paths"]
1668            .as_array()
1669            .unwrap();
1670        assert_eq!(
1671            paths,
1672            &vec![serde_json::json!("auth"), serde_json::json!("users")],
1673            "merged component keeps both dirs as priors"
1674        );
1675        assert_eq!(merged.attributes["layer"], serde_json::json!(LAYER_COMPONENT));
1676    }
1677
1678    #[test]
1679    fn library_architecture_comes_from_exports() {
1680        // A library whose modules share no calls: the public surface graph
1681        // (EXPORT entities + IMPLEMENTS hierarchy + LibrarySdk archetype
1682        // doubling) drives the merge. Three exported classes implementing
1683        // one exported interface across three modules -> one component.
1684        let (store, _t) = store_for();
1685        let repo = store.repo_id.clone();
1686        let si = insert_file_with_symbols(&store, "lib/contracts/base.py", &["iface"]);
1687        let sa = insert_file_with_symbols(&store, "lib/impl_a/a.py", &["ImplA"]);
1688        let sb = insert_file_with_symbols(&store, "lib/impl_b/b.py", &["ImplB"]);
1689
1690        // exported interface + two exported implementations
1691        for (sym, name) in [
1692            (si[0].clone(), "iface"),
1693            (sa[0].clone(), "ImplA"),
1694            (sb[0].clone(), "ImplB"),
1695        ] {
1696            let exp = entity_id(&repo, kinds::EXPORT, name);
1697            store
1698                .insert_entity(&Entity::new(exp.clone(), kinds::EXPORT, name), &["lib/contracts/base.py".into()])
1699                .unwrap();
1700            store
1701                .insert_relationship(
1702                    &Relationship::new(
1703                        format!("rel:exp:{name}"),
1704                        sym.clone(),
1705                        predicates::EXPORTS,
1706                        exp,
1707                        Provenance::Extracted,
1708                    ),
1709                    "lib/contracts/base.py",
1710                )
1711                .unwrap();
1712        }
1713        // impls implement the exported interface (same facade target)
1714        for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1715            store
1716                .insert_relationship(
1717                    &Relationship::new(
1718                        format!("rel:impl:{i}"),
1719                        sym.clone(),
1720                        predicates::IMPLEMENTS,
1721                        si[0].clone(),
1722                        Provenance::Extracted,
1723                    ),
1724                    "lib/impl_a/a.py",
1725                )
1726                .unwrap();
1727        }
1728
1729        let comps = compile(&store);
1730        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1731        // 3 exports / 3 symbols = ratio 1.0 -> LibrarySdk -> public API
1732        // cohesion +8 per pair: all three modules merge into `lib`
1733        assert!(
1734            names.contains(&"lib"),
1735            "export-driven merge into one component: {names:?}"
1736        );
1737        assert!(!names.contains(&"lib/contracts"), "no contracts shell: {names:?}");
1738        assert!(!names.contains(&"lib/impl_a"), "no impl_a shell: {names:?}");
1739        assert!(!names.contains(&"lib/impl_b"), "no impl_b shell: {names:?}");
1740        assert!(names.contains(&"root"), "root shell stays: {names:?}");
1741        let lib = comps.iter().find(|c| c.name == "lib").unwrap();
1742        assert_eq!(lib.attributes["layer"], serde_json::json!(LAYER_COMPONENT));
1743    }
1744
1745    /// Wave 11: two regions whose symbols are BOTH queue consumers (of
1746    /// DIFFERENT topics — no shared-topic event signal) cohere: cross-region
1747    /// call (2) + flow participation (3) + the invocation-surface cohesion
1748    /// signal (2) = 7 >= MERGE_THRESHOLD. The surface signal is decisive —
1749    /// without it the pair sits at 5 < 6 and stays split.
1750    #[test]
1751    // trace:exempt reason=unit-test
1752    fn queue_consumer_regions_cohere_on_surface_family() {
1753        let (store, _t) = store_for();
1754        let repo = store.repo_id.clone();
1755        let sa = insert_file_with_symbols(&store, "jobs-a/consumer.py", &["consume_a"]);
1756        let sb = insert_file_with_symbols(&store, "jobs-b/consumer.py", &["consume_b"]);
1757        for (i, (sym, topic)) in [
1758            (sa[0].clone(), "orders".to_string()),
1759            (sb[0].clone(), "shipments".to_string()),
1760        ]
1761        .iter()
1762        .enumerate()
1763        {
1764            let topic_id = entity_id(&repo, kinds::TOPIC, topic);
1765            store
1766                .insert_entity(
1767                    &Entity::new(topic_id.clone(), kinds::TOPIC, topic),
1768                    &["jobs-a/consumer.py".into()],
1769                )
1770                .unwrap();
1771            store
1772                .insert_relationship(
1773                    &Relationship::new(
1774                        format!("rel:sub:{i}"),
1775                        sym.clone(),
1776                        predicates::SUBSCRIBES,
1777                        topic_id,
1778                        Provenance::Extracted,
1779                    ),
1780                    "jobs-a/consumer.py",
1781                )
1782                .unwrap();
1783        }
1784        // one cross-region call: 2 (call) + 3 (flow — the queue surfaces
1785        // seed flows) = 5; the surface-family signal (+2) pushes to 7
1786        store
1787            .insert_relationship(
1788                &Relationship::new(
1789                    "rel:call",
1790                    sa[0].clone(),
1791                    predicates::CALLS,
1792                    sb[0].clone(),
1793                    Provenance::Extracted,
1794                ),
1795                "jobs-a/consumer.py",
1796            )
1797            .unwrap();
1798
1799        let comps = compile(&store);
1800        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1801        assert!(
1802            names.contains(&"jobs-a+jobs-b"),
1803            "queue-consumer regions cohere into one component: {names:?}"
1804        );
1805        assert!(!names.contains(&"jobs-a"), "no jobs-a shell: {names:?}");
1806        assert!(!names.contains(&"jobs-b"), "no jobs-b shell: {names:?}");
1807    }
1808
1809    #[test]
1810    fn deployment_unit_boundary_is_a_constraint() {
1811        // Two regions in DIFFERENT deployment units with call+state weight
1812        // 6 (>= MERGE_THRESHOLD but <= SERVICE_THRESHOLD): the constraint
1813        // blocks the merge — a split must respect deployment boundaries.
1814        let (store, _t) = store_for();
1815        let repo = store.repo_id.clone();
1816        let sa = insert_file_with_symbols(&store, "svc-a/worker.py", &["a_main"]);
1817        let sb = insert_file_with_symbols(&store, "svc-b/worker.py", &["b_main"]);
1818
1819        for (name, ctx, file) in [
1820            ("du-a", "svc-a", "svc-a/worker.py"),
1821            ("du-b", "svc-b", "svc-b/worker.py"),
1822        ] {
1823            let mut du = Entity::new(entity_id(&repo, kinds::DEPLOYMENT_UNIT, name), kinds::DEPLOYMENT_UNIT, name);
1824            du.attr("build_context", serde_json::json!(ctx));
1825            store.insert_entity(&du, &[file.into()]).unwrap();
1826        }
1827        let db = entity_id(&repo, kinds::DATA_STORE, "db");
1828        store
1829            .insert_entity(&Entity::new(db.clone(), kinds::DATA_STORE, "db"), &["svc-a/worker.py".into()])
1830            .unwrap();
1831        for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1832            store
1833                .insert_relationship(
1834                    &Relationship::new(
1835                        format!("rel:w:{i}"),
1836                        sym.clone(),
1837                        predicates::WRITES,
1838                        db.clone(),
1839                        Provenance::Extracted,
1840                    ),
1841                    "svc-a/worker.py",
1842                )
1843                .unwrap();
1844        }
1845        // one cross-unit call: 4 (state) + 2 (call) = 6 — blocked
1846        store
1847            .insert_relationship(
1848                &Relationship::new(
1849                    "rel:call",
1850                    sa[0].clone(),
1851                    predicates::CALLS,
1852                    sb[0].clone(),
1853                    Provenance::Extracted,
1854                ),
1855                "svc-a/worker.py",
1856            )
1857            .unwrap();
1858
1859        let comps = compile(&store);
1860        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1861        assert!(
1862            names.contains(&"du-a") && names.contains(&"du-b"),
1863            "cross-unit weight 6 must NOT merge: {names:?}"
1864        );
1865        assert!(
1866            !names.iter().any(|n| n.contains('+')),
1867            "no cross-unit merge at 6: {names:?}"
1868        );
1869        // each unit member carries the unit as parent (deployment-unit
1870        // candidate names are the unit names)
1871        for n in ["du-a", "du-b"] {
1872            let c = comps.iter().find(|c| c.name == n).unwrap();
1873            assert_eq!(c.attributes["parent"].as_str().unwrap(), n, "{n}");
1874        }
1875
1876        // ---- escalate to >12: four more cross-unit calls (2 each) make
1877        // the pair weight 4(state) + 10(five calls) = 14 > 12: the merge
1878        // is now ALLOWED and produces one cross-unit component ----
1879        for i in 0..4 {
1880            store
1881                .insert_relationship(
1882                    &Relationship::new(
1883                        format!("rel:call2:{i}"),
1884                        sa[0].clone(),
1885                        predicates::CALLS,
1886                        sb[0].clone(),
1887                        Provenance::Extracted,
1888                    ),
1889                    "svc-a/worker.py",
1890                )
1891                .unwrap();
1892        }
1893        let comps2 = compile(&store);
1894        let names2: Vec<&str> = comps2.iter().map(|c| c.name.as_str()).collect();
1895        assert!(
1896            names2.iter().any(|n| n.contains('+')),
1897            "cross-unit weight >12 merges: {names2:?}"
1898        );
1899    }
1900
1901    #[test]
1902    fn archetype_prior_makes_cli_command_regions_cohere() {
1903        // CLI archetype: two command regions whose combined call+flow
1904        // weight (5) stays below the threshold — the CLI command prior
1905        // (+2) pushes them over 6, so command-region files cohere.
1906        let (store, _t) = store_for();
1907        let sa = insert_file_with_symbols(&store, "cmd/serve/serve.go", &["serve_root"]);
1908        let sb = insert_file_with_symbols(&store, "cmd/version/version.go", &["version_root"]);
1909
1910        // cli-subcommand entrypoints + file attr on both (cli boundary +
1911        // Cli archetype + prior trait)
1912        for (sym, file) in [(sa[0].clone(), "cmd/serve/serve.go"), (sb[0].clone(), "cmd/version/version.go")] {
1913            let mut e = store.get_entity(&sym).unwrap().unwrap();
1914            e.attributes.insert("entrypoints".into(), serde_json::json!(["cli-subcommand"]));
1915            e.attributes.insert("file".into(), serde_json::json!(file));
1916            store.insert_entity(&e, &[file.into()]).unwrap();
1917        }
1918        // one cross-region call: 2 (call) + 3 (flow participation — the
1919        // cli entrypoints seed flows) = 5 < 6 without the prior
1920        store
1921            .insert_relationship(
1922                &Relationship::new(
1923                    "rel:call",
1924                    sa[0].clone(),
1925                    predicates::CALLS,
1926                    sb[0].clone(),
1927                    Provenance::Extracted,
1928                ),
1929                "cmd/serve/serve.go",
1930            )
1931            .unwrap();
1932
1933        let comps = compile(&store);
1934        let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1935        // LCP("cmd/serve", "cmd/version") = "cmd" — the merged commands
1936        // component
1937        assert!(
1938            names.contains(&"cmd"),
1939            "cli command regions cohere into one component: {names:?}"
1940        );
1941        assert!(!names.contains(&"cmd/serve"), "no serve shell: {names:?}");
1942        assert!(!names.contains(&"cmd/version"), "no version shell: {names:?}");
1943        let cmd = comps.iter().find(|c| c.name == "cmd").unwrap();
1944        assert_eq!(
1945            cmd.attributes["boundary_kind"],
1946            serde_json::json!(crate::components::BOUNDARY_CLI),
1947            "merged cli regions keep the cli boundary"
1948        );
1949    }
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960    #[test]
1961    fn clustering_is_deterministic() {
1962        // Same graph compiled twice -> identical component names, layers,
1963        // parents, boundary kinds, and clustering scores.
1964        let (store, _t) = store_for();
1965        let repo = store.repo_id.clone();
1966        let sa = insert_file_with_symbols(&store, "auth/session.py", &["create_session"]);
1967        let sb = insert_file_with_symbols(&store, "users/api.py", &["get_user"]);
1968        let _ = insert_file_with_symbols(&store, "billing/invoice.py", &["make_invoice"]);
1969        let db = entity_id(&repo, kinds::DATA_STORE, "db");
1970        store
1971            .insert_entity(&Entity::new(db.clone(), kinds::DATA_STORE, "db"), &["auth/session.py".into()])
1972            .unwrap();
1973        for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1974            store
1975                .insert_relationship(
1976                    &Relationship::new(
1977                        format!("rel:w:{i}"),
1978                        sym.clone(),
1979                        predicates::WRITES,
1980                        db.clone(),
1981                        Provenance::Extracted,
1982                    ),
1983                    "auth/session.py",
1984                )
1985                .unwrap();
1986        }
1987        store
1988            .insert_relationship(
1989                &Relationship::new(
1990                    "rel:call",
1991                    sa[0].clone(),
1992                    predicates::CALLS,
1993                    sb[0].clone(),
1994                    Provenance::Extracted,
1995                ),
1996                "auth/session.py",
1997            )
1998            .unwrap();
1999
2000        let c1 = compile(&store);
2001        let c2 = compile(&store);
2002        assert_eq!(c1.len(), c2.len());
2003        for (a, b) in c1.iter().zip(c2.iter()) {
2004            assert_eq!(a.name, b.name);
2005            assert_eq!(a.attributes.get("layer"), b.attributes.get("layer"), "{}", a.name);
2006            assert_eq!(a.attributes.get("parent"), b.attributes.get("parent"), "{}", a.name);
2007            assert_eq!(
2008                a.attributes.get("boundary_kind"),
2009                b.attributes.get("boundary_kind"),
2010                "{}",
2011                a.name
2012            );
2013            assert_eq!(
2014                a.attributes.get("clustering_score"),
2015                b.attributes.get("clustering_score"),
2016                "{}",
2017                a.name
2018            );
2019        }
2020        // the merged auth+users component appears in both runs
2021        assert!(c1.iter().any(|c| c.name == "auth+users"));
2022        assert!(c2.iter().any(|c| c.name == "auth+users"));
2023    }
2024}