sidegraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sidegraph/domains.py ADDED
@@ -0,0 +1,902 @@
1
+ """Bootstrap ``Domain`` proposals from graph communities (portable core, path 1 of the
2
+ three authoring paths — see docs/concepts/mind-model.md#domain-lifecycle).
3
+
4
+ Reads communities via the engine seam (``GraphifyReader.communities()`` +
5
+ ``community_labels()``) and writes proposals through ``store.add_domain`` — same
6
+ "deterministic pipeline, status=proposed" shape as ``importer.import_rationales`` and
7
+ ``capture.propose_domains``, but scanning the graph's own community structure instead of
8
+ rationale nodes or agent drafts. One ``Domain`` draft per *significant* community (member
9
+ count, counting only anchorable nodes, at or above ``min_members``): slug/title/summary
10
+ prefer the engine's community label when the corpus has one, falling back to a
11
+ deterministic god-node-derived name/digest otherwise. Everything lands ``status=proposed``
12
+ — bootstrap is not exempt from the single ratification gate (§4).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+
19
+ from pydantic import BaseModel, Field
20
+
21
+ from .capture import (
22
+ AutoEligibility,
23
+ RatifyPolicy,
24
+ _auto_ratify,
25
+ _domain_anchored,
26
+ _lint_domain_path_prefixes,
27
+ auto_ratify_eligible,
28
+ redact,
29
+ )
30
+ from .engine.reader import ANCHORABLE_FILE_TYPES, Community, GraphifyReader
31
+ from .retrieval import TOC_CACHE_KEY, build_toc
32
+ from .schema import Descriptor, Domain, DomainStatus, Provenance, matches_path_prefix, slugify
33
+ from .store import Store
34
+ from .sync import activate_accepted_domain
35
+
36
+ # Below this fraction of anchorable members sharing a single top-level directory,
37
+ # path_prefixes is left empty rather than derived from a weak majority (§4.1: "≥80% of
38
+ # members share a top-level dir").
39
+ _PATH_PREFIX_THRESHOLD = 0.8
40
+
41
+ # How many member names surface in the deterministic digest fallback summary
42
+ # ("N entities incl. a, b, c — around <top file>").
43
+ _DIGEST_SAMPLE_SIZE = 3
44
+
45
+ # Well-known shared/infra top-level directory names: NEVER derived as a path_prefixes
46
+ # stabilizer, even when a community's members overwhelmingly share one of them. Gate-5
47
+ # blocker: a community that clusters with its own tests/ (>=80% of its anchorable members
48
+ # live there) is the NORMAL case for a god-node community with per-module tests, not
49
+ # evidence "tests/" is that community's home — deriving it as a stabilizer let sync's
50
+ # REPLACE refresh (see sync._recompute_domain_communities) legitimately, and silently,
51
+ # expand the domain to swallow every OTHER community that also ships tests/ (true of any
52
+ # community in the corpus). See docs/guides/naming-your-domains.md.
53
+ _SHARED_DIR_NAMES = frozenset(
54
+ {
55
+ "test",
56
+ "tests",
57
+ "src",
58
+ "lib",
59
+ "docs",
60
+ "doc",
61
+ "examples",
62
+ "scripts",
63
+ "tools",
64
+ "utils",
65
+ }
66
+ )
67
+
68
+ # A path-derived candidate is also skipped when the communities sharing its winning
69
+ # directory — the candidate's own community INCLUDED — already add up to more than this
70
+ # fraction of ALL communities in the graph. Both the numerator (the claimed set, computed
71
+ # by _prefix_community_breadth) and the denominator (the total community count) count the
72
+ # candidate's own community; this is deliberately the SAME ratio
73
+ # sync._recompute_domain_communities computes for its refresh-time _DOMAIN_CLAIM_CAP
74
+ # (Option A, owner-approved 2026-09-14: the two guards used to disagree — derive excluded
75
+ # the candidate's own community from its "other communities" count while refresh included
76
+ # it, so bootstrap could derive a stabilizer the very next refresh rejected as overbroad.
77
+ # Tightening derive to match refresh's numerator, over the SAME graph snapshot, is the
78
+ # goal — a rule this guard lets through should never trip the refresh cap AT THE MOMENT
79
+ # IT IS DERIVED. That depended on _majority_top_dir and _prefix_community_breadth
80
+ # decomposing file_path the SAME way, which round 1 alone did not achieve — see
81
+ # _prefix_community_breadth's docstring, NEW Major A, fix round 2. This says nothing
82
+ # about a LATER graph rebuild either way — a directory one community owns exclusively
83
+ # today can be shared by five tomorrow, and refresh capping the rule then is the design
84
+ # working as intended, not a regression of this guarantee). Generalizes the
85
+ # _SHARED_DIR_NAMES guard past its fixed name list to any cross-cutting directory pattern
86
+ # (vendored deps, generated code, ...).
87
+ _PREFIX_BREADTH_CAP = 0.2
88
+
89
+ # Scale-aware default `limit` (finding B, scale-robustness hardening wave): on a monorepo-
90
+ # scale corpus (Airflow: 5,517 communities, 2,578 significant) an unbounded
91
+ # `list_domain_candidates` call is a ~276K-token dump — ~140x a normal budget. 100 is the
92
+ # measured sweet spot (~10K tokens). This is a PUBLIC-SURFACE default applied by the
93
+ # `list_domain_candidates` MCP tool and the `sidegraph-domains bootstrap` CLI, one shared
94
+ # constant so the two stay consistent by construction; `collect_domain_candidates`'s own
95
+ # `limit` parameter default stays `None` (unlimited) -- callers that want everything
96
+ # (tests included) never have to fight a collector-level default.
97
+ DEFAULT_CANDIDATE_LIMIT = 100
98
+
99
+
100
+ class BootstrapReport(BaseModel):
101
+ """Counts (+ dry-run-only listing) for one ``bootstrap_domains`` run.
102
+
103
+ ``proposed``/``skipped_existing`` reflect what WOULD happen whether or not
104
+ ``dry_run`` actually wrote anything (mirrors ``ImportReport``); ``below_threshold``
105
+ and ``filtered`` explain why a community was never even considered a candidate.
106
+ ``dry_run`` carries the listing and is populated only on a dry run.
107
+
108
+ ``total_before_limit`` (finding B/C) mirrors ``CollectStats.total_before_limit`` — the
109
+ full significant-community count BEFORE ``limit`` truncated it, computed either way
110
+ (dry run or not). The CLI uses it to note when ``--limit`` (default or explicit) cut
111
+ candidates the run never even considered, in BOTH the ``--dry-run`` listing and the
112
+ real, pre-write threshold check (BUG C: that check runs off a separate, read-only
113
+ ``collect_domain_candidates`` call made BEFORE ``bootstrap_domains`` writes anything —
114
+ see ``cli._domains_bootstrap``).
115
+ """
116
+
117
+ proposed: int = 0
118
+ skipped_existing: int = 0
119
+ below_threshold: int = 0
120
+ filtered: int = 0
121
+ total_before_limit: int = 0
122
+ # Auto-ratification policy (design D2/D6) -- additive/defaulted, same contract as
123
+ # importer.ImportReport's/doc_import.DocImportReport's own pair: incremented/appended
124
+ # by the post-write auto block below, always empty/zero under `manual`/`auto-low-risk`
125
+ # (domains are `auto-all`-only, D3) or when a proposed domain was ineligible. A domain
126
+ # accepted before its activation step fails increments this AND appends the
127
+ # activation failure -- the counter reports canonical acceptance, not a clean run.
128
+ auto_ratified: int = 0
129
+ auto_ratify_failures: list[str] = Field(default_factory=list) # ["<domain id>: <reason>"]
130
+ dry_run: list[dict] = Field(default_factory=list) # [{"community_id", "slug", ...}, ...]
131
+ # design D7.4 (staleness-machinery wave, E8 gate checklist): deterministic lint
132
+ # warnings, one entry per candidate that has >= 1 (see capture._lint_domain_path_
133
+ # prefixes) -- {"slug", "warnings": [...]}. Populated on BOTH dry-run and real runs;
134
+ # advisory only, never blocks the write -- under `auto-all` any warning keeps the
135
+ # draft proposed.
136
+ warnings: list[dict] = Field(default_factory=list)
137
+
138
+
139
+ class DomainCandidate(BaseModel):
140
+ """One significant community's fully-resolved bootstrap proposal, as computed by
141
+ ``collect_domain_candidates`` — the single source of truth ``bootstrap_domains``
142
+ (which writes it) and the read-only ``list_domain_candidates`` MCP tool (which only
143
+ displays it) both build on, so the tool always shows exactly what the CLI would
144
+ propose (see domain-onboarding design §1)."""
145
+
146
+ community_id: str
147
+ suggested_slug: str
148
+ suggested_title: str
149
+ summary: str
150
+ path_prefixes: list[str] = Field(default_factory=list)
151
+ member_count: int
152
+ top_members: list[str] = Field(default_factory=list) # <= _DIGEST_SAMPLE_SIZE names
153
+ top_file: str | None = None
154
+ has_label: bool = False # a validated engine label survived _label_names_other_community
155
+ # The community's god-node resolved to a durable name+file_path Descriptor (§2a
156
+ # amendment) -- what `list_domain_candidates` surfaces so the name-domains skill can
157
+ # build a Domain.seed_anchors entry from a candidate, rather than the volatile
158
+ # community_id alone (which does not survive a fresh clone/rebuild). None only when the
159
+ # community has no god node, or the god node itself has no name (defensive).
160
+ anchor: Descriptor | None = None
161
+
162
+
163
+ class CollectStats(BaseModel):
164
+ """Selection counts for one ``collect_domain_candidates`` run — the shared basis for
165
+ ``BootstrapReport``'s fields and ``list_domain_candidates``'s ``skipped``/
166
+ ``already_claimed`` output. ``already_claimed`` folds together the two "this
167
+ community isn't a NEW candidate" reasons the collector's Pass 1 can hit — the
168
+ community itself already claimed by a non-superseded domain, and a computed slug
169
+ colliding with one already in the store — matching ``BootstrapReport.skipped_existing``
170
+ exactly (see ``collect_domain_candidates``'s own docstring, "Idempotent:" paragraph,
171
+ for why both count as "existing").
172
+
173
+ ``total_before_limit`` (finding B) is the significant (threshold/path-filter-surviving)
174
+ community count BEFORE ``limit`` truncates it — cheap to compute (a bare ``len()`` right
175
+ after the filter loop, no per-candidate label/summary/path_prefixes work, which stays
176
+ bounded to at most ``limit`` items). This is the basis for ``list_domain_candidates``'s
177
+ ``truncated``/``total_significant`` signaling and ``sidegraph-domains bootstrap``'s
178
+ before-write over-threshold nag: comparing it against the requested ``limit`` answers
179
+ "were there more candidates than what got shown/written?" independent of the SEPARATE
180
+ already-claimed skip, which only ever narrows the (possibly already-limited) survivor
181
+ set further and says nothing about how many candidates existed in total."""
182
+
183
+ total: int = 0
184
+ total_before_limit: int = 0
185
+ already_claimed: int = 0
186
+ below_threshold: int = 0
187
+ filtered: int = 0
188
+
189
+
190
+ def _anchorable_member_ids(community_members: list[str], reader: GraphifyReader) -> list[str]:
191
+ out = []
192
+ for node_id in community_members:
193
+ node = reader.get_node(node_id)
194
+ if node is not None and node.file_type in ANCHORABLE_FILE_TYPES:
195
+ out.append(node_id)
196
+ return out
197
+
198
+
199
+ def _matches_any_prefix(node_ids: list[str], prefixes: list[str], reader: GraphifyReader) -> bool:
200
+ for node_id in node_ids:
201
+ node = reader.get_node(node_id)
202
+ if (
203
+ node is not None
204
+ and node.file_path is not None
205
+ and any(matches_path_prefix(node.file_path, p) for p in prefixes)
206
+ ):
207
+ return True
208
+ return False
209
+
210
+
211
+ def _top_file(
212
+ anchorable_ids: list[str],
213
+ god_node_id: str | None,
214
+ reader: GraphifyReader,
215
+ ) -> str | None:
216
+ """The god node's file, when it has one; else the most common file_path among
217
+ members (a deterministic stand-in for "where this community lives" when no single
218
+ node dominates by degree)."""
219
+ if god_node_id is not None:
220
+ god = reader.get_node(god_node_id)
221
+ if god is not None and god.file_path:
222
+ return god.file_path
223
+ counts: dict[str, int] = {}
224
+ for node_id in anchorable_ids:
225
+ node = reader.get_node(node_id)
226
+ if node is not None and node.file_path:
227
+ counts[node.file_path] = counts.get(node.file_path, 0) + 1
228
+ if not counts:
229
+ return None
230
+ return max(counts.items(), key=lambda kv: kv[1])[0]
231
+
232
+
233
+ def _god_node_descriptor(god_node_id: str | None, reader: GraphifyReader) -> Descriptor | None:
234
+ """The community's god-node resolved to a durable ``Descriptor`` (name + file_path) —
235
+ what a ``Domain.seed_anchors`` entry needs to durably anchor to this community across
236
+ clones/rebuilds (§2a amendment). ``None`` when the community has no god node, or the
237
+ god node has no name (defensive — mirrors the blank-name guard ``collect_domain_
238
+ candidates`` already applies to the god-node title fallback)."""
239
+ if god_node_id is None:
240
+ return None
241
+ node = reader.get_node(god_node_id)
242
+ if node is None or not node.name.strip():
243
+ return None
244
+ return Descriptor(name=node.name, file_path=node.file_path)
245
+
246
+
247
+ def _top_member_names(
248
+ anchorable_ids: list[str],
249
+ reader: GraphifyReader,
250
+ limit: int = _DIGEST_SAMPLE_SIZE,
251
+ ) -> list[str]:
252
+ """First ``limit`` named anchorable members, in member-list order — the same sample
253
+ ``_member_digest``'s "incl. a, b, c" prose draws from, also surfaced verbatim as a
254
+ ``DomainCandidate``'s ``top_members`` for the ``list_domain_candidates`` tool."""
255
+ names: list[str] = []
256
+ for node_id in anchorable_ids:
257
+ node = reader.get_node(node_id)
258
+ if node is not None and node.name:
259
+ names.append(node.name)
260
+ if len(names) >= limit:
261
+ break
262
+ return names
263
+
264
+
265
+ def _member_digest(
266
+ anchorable_ids: list[str],
267
+ god_node_id: str | None,
268
+ reader: GraphifyReader,
269
+ ) -> str:
270
+ """Deterministic fallback summary when the engine has no label for this community:
271
+ "N entities incl. a, b, c — around <top file>"."""
272
+ names = _top_member_names(anchorable_ids, reader)
273
+ incl = ", ".join(names) if names else "unnamed members"
274
+ n = len(anchorable_ids)
275
+ top_file = _top_file(anchorable_ids, god_node_id, reader)
276
+ if top_file:
277
+ return f"{n} entities incl. {incl} — around {top_file}"
278
+ return f"{n} entities incl. {incl}"
279
+
280
+
281
+ def _prefix_community_breadth(reader: GraphifyReader) -> tuple[dict[str, set[str]], int]:
282
+ """Precompute, once per bootstrap run: for every top-level directory that appears in
283
+ an anchorable node's file_path ANYWHERE in the graph, the set of community ids that
284
+ have >= 1 such node, plus the total number of distinct communities in the graph. This
285
+ is the denominator/lookup ``_derive_path_prefixes``'s breadth guard needs (see
286
+ ``_PREFIX_BREADTH_CAP``) — computed once rather than per-candidate-community, since
287
+ it depends on the whole graph, not any one community's members.
288
+
289
+ The index key is ``file_path.split("/", 1)[0]`` — the text up to (not including) the
290
+ first ``"/"``, or the whole string when there is none (fix round 1, Major 1: a
291
+ ``PurePosixPath`` + "skip anything under 2 parts" version used here before missed a
292
+ ``file_path`` that IS the bare directory name, e.g. ``"trader"``, or that name plus a
293
+ trailing slash, e.g. ``"trader/"`` — both match ``schema.matches_path_prefix`` at
294
+ refresh time but were invisible to derive's breadth count, so a community sharing a
295
+ directory only through such a node could trip the refresh cap without derive ever
296
+ seeing it coming). No length filter: a bare ``"trader"`` node contributes to
297
+ ``dir_to_communities["trader"]`` exactly as ``"trader/x.py"`` does, same as
298
+ ``matches_path_prefix("trader", "trader")`` is ``True``.
299
+
300
+ ``_majority_top_dir`` decomposes each candidate's OWN members' paths with
301
+ ``str.partition("/")`` for the identical head value (fix round 2, NEW Major A: it was
302
+ still on ``PurePosixPath`` when this function's key moved above, so the two disagreed
303
+ on an absolute ``file_path`` — ``PurePosixPath("/a/x.py").parts[0]`` is ``"/"``, this
304
+ function's key for the same path is ``""`` — and derive could pick a majority top
305
+ directory this dict had no entry for at all, so the single-community floor waved it
306
+ through unchecked). ``_majority_top_dir`` additionally REJECTS an empty head (an
307
+ absolute path can never become a majority top directory — see its own docstring), so
308
+ every ``top_dir`` this module can actually derive is now a key this dict's lookup
309
+ treats the identical way ``matches_path_prefix`` would.
310
+ """
311
+ dir_to_communities: dict[str, set[str]] = {}
312
+ all_communities: set[str] = set()
313
+ for node in reader.list_nodes():
314
+ if node.community is not None:
315
+ all_communities.add(node.community)
316
+ if node.file_type not in ANCHORABLE_FILE_TYPES or not node.file_path:
317
+ continue
318
+ if node.community is None:
319
+ continue
320
+ top_dir = node.file_path.split("/", 1)[0]
321
+ dir_to_communities.setdefault(top_dir, set()).add(node.community)
322
+ return dir_to_communities, len(all_communities)
323
+
324
+
325
+ def _majority_top_dir(anchorable_ids: list[str], reader: GraphifyReader) -> str | None:
326
+ """The single top-level directory shared by >= ``_PATH_PREFIX_THRESHOLD`` of the
327
+ members that HAVE a file_path (and a directory component); else ``None`` — a weak
328
+ majority is worse than no signal at all. The bare majority calc, with neither of
329
+ ``_derive_path_prefixes``'s two stabilizer-only vetoes applied — reused by
330
+ ``community_group_path`` for ``list_domain_candidates``'s presentational grouping
331
+ (a structure HINT, never a membership rule, so those vetoes don't apply there).
332
+
333
+ Decomposes each ``file_path`` with ``str.partition("/")`` — ``head, sep, _rest`` —
334
+ the SAME single decomposition ``_prefix_community_breadth`` uses, and equivalent to
335
+ ``schema.matches_path_prefix`` for every head this function can return (fix round 2,
336
+ NEW Major A: this function was left on ``PurePosixPath`` when round 1 moved breadth's
337
+ own key to ``split("/", 1)[0]``, so the two disagreed on an absolute path —
338
+ ``PurePosixPath("/a/x.py").parts[0]`` is ``"/"``, the partition head is ``""`` — and
339
+ derive could pick a majority directory breadth had no record of at all). NOT the same
340
+ decomposition ``matches_path_prefix`` itself performs — that function never
341
+ partitions anything, it compares with ``==``/``startswith`` — merely one that always
342
+ agrees with it for a single-segment prefix (measured exhaustively over 9,331
343
+ synthetic paths and every real corpus in the workspace, fix round 2 review).
344
+
345
+ A member "has a directory component" (counts toward the vote) only when there IS a
346
+ separator AND the head is non-empty: no separator (``"x.py"``) is skipped exactly as
347
+ before the round-2 fix; an EMPTY head (``"/a/x.py"``, ``"//a/x.py"``) is skipped too
348
+ — an absolute path can never become a majority top directory, since after
349
+ ``matches_path_prefix`` strips a trailing slash from an empty-string prefix, it would
350
+ match every absolute path AND every empty ``file_path`` in the graph. This "no
351
+ separator" skip is DELIBERATELY narrower than ``_prefix_community_breadth``'s own
352
+ filter (fix round 3, NEW Minor C: a bare ``"trader"`` node still indexes under
353
+ ``dir_breadth["trader"]`` there, unchanged — see that function's docstring) because
354
+ the two functions answer different questions over the SAME decomposed head: this one
355
+ asks which directory a community's OWN members mostly live in — a member with no
356
+ separator has no directory to vote for, full stop — while breadth asks which
357
+ candidate top directories a refresh with prefix D would go on to MATCH — and
358
+ ``matches_path_prefix(fp, fp)`` is ``True`` for a bare name matching itself exactly,
359
+ so a bare-name node must still be indexed under its own name for that question. A
360
+ majority function that let bare names vote would propose file names like
361
+ ``"pyproject.toml"`` as directory stabilizers on a real corpus, instead of a
362
+ community's actual home directory (or no stabilizer, correctly, when there is
363
+ none) — mutant F15 (fix round 3 review) is this exact veto removed, and
364
+ ``test_bootstrap_majority_ignores_a_root_level_file_like_a_missing_vote`` below pins
365
+ it."""
366
+ dirs: dict[str, int] = {}
367
+ total = 0
368
+ for node_id in anchorable_ids:
369
+ node = reader.get_node(node_id)
370
+ if node is None or not node.file_path:
371
+ continue
372
+ head, sep, _rest = node.file_path.partition("/")
373
+ if not sep or not head:
374
+ continue # no directory component to group by (bare name, or an absolute path)
375
+ total += 1
376
+ dirs[head] = dirs.get(head, 0) + 1
377
+ if total == 0:
378
+ return None
379
+ top_dir, count = max(dirs.items(), key=lambda kv: kv[1])
380
+ if count / total < _PATH_PREFIX_THRESHOLD:
381
+ return None
382
+ return top_dir
383
+
384
+
385
+ def _derive_path_prefixes(
386
+ anchorable_ids: list[str],
387
+ community_id: str,
388
+ reader: GraphifyReader,
389
+ dir_breadth: dict[str, set[str]],
390
+ total_communities: int,
391
+ ) -> list[str]:
392
+ """``[_majority_top_dir(...)]`` when there is one; else ``[]``.
393
+
394
+ Past the majority check, two more guards (Gate-5 finding, see the module-level
395
+ ``_SHARED_DIR_NAMES``/``_PREFIX_BREADTH_CAP`` docstrings) can still veto the winning
396
+ directory back to ``[]``: (a) it's a well-known shared/infra directory name, or (b) the
397
+ communities sharing it — its own community INCLUDED, via ``dir_breadth`` from
398
+ ``_prefix_community_breadth`` — already add up to more than 20% of ALL communities in
399
+ the graph today (the same claimed-set-over-total ratio
400
+ ``sync._recompute_domain_communities`` caps at refresh time, over the SAME graph
401
+ snapshot; the single-community floor below is the identical floor that guard has, for
402
+ the identical reason). This depends on ``top_dir`` itself and ``dir_breadth`` agreeing
403
+ on what a directory name IS (fix round 2, NEW Major A: ``_majority_top_dir`` used to
404
+ decompose ``file_path`` differently than ``_prefix_community_breadth`` did, so a
405
+ ``top_dir`` this function picked could be a key ``dir_breadth`` had never populated at
406
+ all — the single-community floor then waved an absolute-path directory like ``"/"``
407
+ straight through; see both functions' own docstrings). Either veto is evidence the
408
+ directory is a cross-cutting pattern, not this community's own home, and deriving it
409
+ as a stabilizer would let sync's REPLACE refresh silently swallow every other
410
+ community that happens to share it too — today. A later graph rebuild can still grow
411
+ a directory's share past the cap; refresh capping the rule then is this guard's own
412
+ defense-in-depth working as designed, not a hole in what this function promises.
413
+ """
414
+ top_dir = _majority_top_dir(anchorable_ids, reader)
415
+ if top_dir is None:
416
+ return []
417
+ if top_dir in _SHARED_DIR_NAMES:
418
+ return []
419
+ if total_communities > 0:
420
+ claimed = dir_breadth.get(top_dir, set()) | {community_id}
421
+ if len(claimed) > 1 and len(claimed) / total_communities > _PREFIX_BREADTH_CAP:
422
+ return []
423
+ return [top_dir]
424
+
425
+
426
+ def community_group_path(
427
+ community_id: str,
428
+ reader: GraphifyReader,
429
+ communities_by_id: dict[str, Community] | None = None,
430
+ ) -> str | None:
431
+ """The presentational top-level-directory grouping hint for ``community_id`` — the
432
+ SAME majority-dir calc ``_derive_path_prefixes`` uses for its stabilizer rule, minus
433
+ both of its vetoes (a structure HINT only, per domain-onboarding design §1's "hybrid"
434
+ grouping — never a membership rule, so the vetoes that protect ``path_prefixes`` from
435
+ becoming an over-broad sync stabilizer don't apply here). Used by
436
+ ``list_domain_candidates`` to group a ``DomainCandidate`` that didn't itself derive a
437
+ ``path_prefixes`` stabilizer. ``communities_by_id``, when given, skips re-walking
438
+ ``reader.communities()`` per candidate — the caller builds it once."""
439
+ by_id = communities_by_id
440
+ if by_id is None:
441
+ by_id = {c.community_id: c for c in reader.communities()}
442
+ community = by_id.get(community_id)
443
+ if community is None:
444
+ return None
445
+ anchorable = _anchorable_member_ids(community.members, reader)
446
+ return _majority_top_dir(anchorable, reader)
447
+
448
+
449
+ def _dedupe_identical_prefix_sets(
450
+ pending: list[dict],
451
+ reader: GraphifyReader,
452
+ dir_breadth: dict[str, set[str]],
453
+ total_communities: int,
454
+ ) -> dict[str, list[str]]:
455
+ """Derive ``path_prefixes`` for every ``pending`` bootstrap candidate this run WILL
456
+ propose, then dedupe: when >= 2 of them derive the IDENTICAL non-empty prefix set
457
+ (live finding — two communities each independently >= 80% under the same top-level
458
+ directory, e.g. both under 'trader/'), only the candidate with the MOST anchorable
459
+ members keeps it; the rest fall back to ``[]`` (title/summary/slug untouched, still
460
+ proposed). Ties on member count break on the LOWER community id
461
+ (``_community_sort_key`` — same numeric-aware ordering ``bootstrap_domains`` already
462
+ uses for candidate ordering, so the winner is deterministic across runs of the same
463
+ graph).
464
+
465
+ Without this, two domains racing for the same directory stabilizer within one run
466
+ would both carry a ``path_prefixes`` rule that matches the SAME code — a later sync
467
+ REPLACE refresh (``sync._recompute_domain_communities``) would then let both domains'
468
+ ``communities`` legitimately point at (some of) the same nodes, the identical
469
+ "silently-overlapping memory" shape the Gate-5 breadth guard exists to prevent, just
470
+ triggered by two rules agreeing with each other instead of one rule alone reaching too
471
+ broad.
472
+ """
473
+ derived: dict[str, list[str]] = {
474
+ p["community_id"]: _derive_path_prefixes(
475
+ p["anchorable"], p["community_id"], reader, dir_breadth, total_communities
476
+ )
477
+ for p in pending
478
+ }
479
+ member_counts = {p["community_id"]: len(p["anchorable"]) for p in pending}
480
+
481
+ by_prefix_set: dict[tuple[str, ...], list[str]] = {}
482
+ for community_id, prefixes in derived.items():
483
+ if prefixes:
484
+ by_prefix_set.setdefault(tuple(prefixes), []).append(community_id)
485
+
486
+ for community_ids in by_prefix_set.values():
487
+ if len(community_ids) < 2:
488
+ continue
489
+ winner = min(
490
+ community_ids,
491
+ key=lambda cid: (-member_counts[cid], _community_sort_key(cid)),
492
+ )
493
+ for cid in community_ids:
494
+ if cid != winner:
495
+ derived[cid] = []
496
+
497
+ return derived
498
+
499
+
500
+ def _dedupe_slug(base: str, used: set[str]) -> str:
501
+ """Disambiguate two communities that would otherwise mint the same slug within THIS
502
+ run (e.g. two communities both god-node-named ``utils.py``) with -2/-3 suffixes.
503
+ Collisions against slugs already living in the store are handled separately, as a
504
+ skip (see the idempotency check in ``bootstrap_domains``) — never suffixed."""
505
+ if base not in used:
506
+ used.add(base)
507
+ return base
508
+ i = 2
509
+ candidate = f"{base}-{i}"
510
+ while candidate in used:
511
+ i += 1
512
+ candidate = f"{base}-{i}"
513
+ used.add(candidate)
514
+ return candidate
515
+
516
+
517
+ def _community_sort_key(community_id: str) -> tuple[int, object]:
518
+ """Numeric communities sort numerically; anything else falls back to lexicographic —
519
+ keeps bootstrap output (and --limit's meaning) deterministic across runs of the same
520
+ graph.json regardless of dict/set iteration order."""
521
+ try:
522
+ return (0, int(community_id))
523
+ except ValueError:
524
+ return (1, community_id)
525
+
526
+
527
+ def _claimed_communities(store: Store) -> set[str]:
528
+ """Community ids already claimed by a non-superseded domain (proposed, accepted, OR
529
+ dropped — matches ``find_domain_by_slug``'s "non-superseded" rule exactly, see
530
+ capture.py's ``_propose_domain_one``)."""
531
+ claimed: set[str] = set()
532
+ for domain in store.iter_domains():
533
+ if domain.status == DomainStatus.SUPERSEDED:
534
+ continue
535
+ claimed.update(domain.communities)
536
+ return claimed
537
+
538
+
539
+ def _label_names_other_community(label: str, community_id: str, reader: GraphifyReader) -> bool:
540
+ """True when ``label`` is demonstrably the engine's name for an entity that
541
+ ``reader``'s CURRENT graph places in a community other than ``community_id``.
542
+
543
+ Guards against a real-corpus finding (independent evaluation, 2026-07-08): a
544
+ community's label in ``.graphify_labels.json`` does not always correspond to the
545
+ SAME community partition as the graph.json sitting next to it — verified even when
546
+ both come from the exact same ``graph_version`` (so not stale/drifted engine data,
547
+ just the label pass and the community field disagreeing on numbering). Trusting such
548
+ a label verbatim would title/slug a domain after an entity that isn't actually in the
549
+ community the domain stores, e.g. a domain named "OrderBook" whose stored community
550
+ is all TA-strategy files because the label sidecar's community numbering slipped.
551
+
552
+ Uses ``GraphifyReader.resolve`` — the same name -> node(s) -> community resolution
553
+ ``capture``'s anchor binding already relies on — rather than re-deriving anything
554
+ here:
555
+
556
+ - ``resolved`` (one match): rejected when that node's community differs from
557
+ ``community_id``.
558
+ - ``ambiguous`` (several same-named nodes): each candidate's OWN community is looked
559
+ up (``ResolveResult.candidates`` only carries node ids); rejected ONLY when NONE of
560
+ them live in ``community_id`` — i.e. every candidate is provably elsewhere. A label
561
+ with at least one candidate in ``community_id`` is plausibly correct and kept, even
562
+ though other, same-named entities exist elsewhere too (measured on the real corpus:
563
+ 32/259 proposals had a label with zero candidates in its own community — all
564
+ provably wrong; the remaining ambiguous labels all had a candidate that matched,
565
+ and rejecting those too would have thrown away otherwise-correct titles).
566
+ - ``unresolved`` (no node anywhere has this name — free-form descriptive prose, e.g.
567
+ "Alpha Domain", a legitimate, tested label shape, see
568
+ ``test_bootstrap_labeled_community_uses_label_for_title_and_summary``): no evidence
569
+ against it, kept.
570
+ """
571
+ result = reader.resolve(Descriptor(name=label))
572
+ if result.status == "unresolved":
573
+ return False
574
+ if result.status == "resolved":
575
+ return result.community is not None and result.community != community_id
576
+ candidate_communities = {
577
+ node.community
578
+ for node in (reader.get_node(node_id) for node_id in result.candidates)
579
+ if node is not None and node.community is not None
580
+ }
581
+ return bool(candidate_communities) and community_id not in candidate_communities
582
+
583
+
584
+ def collect_domain_candidates(
585
+ store: Store,
586
+ reader: GraphifyReader,
587
+ *,
588
+ min_members: int = 5,
589
+ paths: list[str] | None = None,
590
+ limit: int | None = None,
591
+ ) -> tuple[list[DomainCandidate], CollectStats]:
592
+ """The deterministic candidate-selection body shared by ``bootstrap_domains`` (which
593
+ then writes each survivor as a ``Domain``) and the read-only ``list_domain_candidates``
594
+ MCP tool (which only displays them) — one source of truth, so the tool always shows
595
+ exactly what the CLI would propose (domain-onboarding design §1).
596
+
597
+ A community is *significant* when its anchorable member count (code/document/
598
+ concept/rationale nodes only — never-guess extends to file types, same filter as
599
+ ``GraphifyReader.ANCHORABLE_FILE_TYPES``) is ``>= min_members``. ``paths``, when
600
+ given, further restricts candidates to communities with at least one anchorable
601
+ member whose ``file_path`` starts with one of the given prefixes (mirrors
602
+ ``import_rationales``'s per-node ``--path`` filter, applied existentially at the
603
+ community level since a community has no single file_path of its own). ``limit``
604
+ caps the number of *significant* communities considered, applied after the
605
+ threshold/path filters in a deterministic (community-id-sorted) order — same
606
+ ordering rule ``import_rationales`` uses for its own ``kept[:limit]`` slice.
607
+ ``None`` (this function's own default) means unlimited; the public surfaces built on
608
+ top of this collector (the ``list_domain_candidates`` MCP tool, ``sidegraph-domains
609
+ bootstrap``) apply their own scale-aware default (``DEFAULT_CANDIDATE_LIMIT``, finding
610
+ B) instead of inheriting this one, so a caller reaching for the collector directly (a
611
+ test, a script) never has to fight an implicit cap. The returned ``CollectStats.
612
+ total_before_limit`` names the full significant count regardless of what ``limit``
613
+ trimmed it to — repeatedly bootstrapping under the SAME low limit only ever proposes
614
+ the same low community-id-sorted window (already-claimed communities in that window
615
+ get skipped on a rerun, but communities past the window are never reached); widen
616
+ ``limit`` (or narrow ``min_members``/``paths``) to get past it.
617
+
618
+ Per significant community: slug/title come from the engine's community label
619
+ (``GraphifyReader.community_labels()``) when present, else the community's god-node
620
+ name; summary is the label itself when present, else a deterministic member digest
621
+ ("N entities incl. a, b, c — around <top file>"). A label that ``resolve()`` shows
622
+ names an entity actually living in a DIFFERENT community is rejected first and never
623
+ reaches title/summary — see ``_label_names_other_community`` — since the community
624
+ field a label's key was written against isn't guaranteed to match this graph.json's
625
+ (real-corpus finding: a domain's own namesake god node ended up outside the community
626
+ the domain stored). ``path_prefixes`` is derived only
627
+ when >= 80% of members share a single top-level directory AND that directory is
628
+ neither a well-known shared/infra name (``tests/``, ``src/``, ...) nor already claimed
629
+ (own community included) by communities adding up to > 20% of ALL communities in the
630
+ graph (Gate-5 guard — see ``_derive_path_prefixes``); either trips leaves
631
+ ``path_prefixes`` empty rather than a stabilizer that would let a later sync silently
632
+ swallow unrelated communities.
633
+ Redaction runs on
634
+ title/summary/slug regardless of origin (§4.1: "bootstrap summaries derive from
635
+ engine data, redact anyway" — the graph can embed doc/rationale text a human typed);
636
+ the slug is derived from the already-redacted title, never the raw label/god-node
637
+ name, so secret material can't leak into the repo-committed slug or its paired
638
+ `domain:<slug>` entity name.
639
+
640
+ Each candidate also carries ``anchor`` — the community's god-node resolved to a durable
641
+ name+file_path ``Descriptor`` (§2a amendment, unredacted — a code/doc symbol name, not
642
+ free-form prose). ``list_domain_candidates`` surfaces this so the ``name-domains``
643
+ skill can build a ``Domain.seed_anchors`` entry directly from a candidate instead of
644
+ the volatile ``community_id`` alone, which does not survive a fresh clone or rebuild.
645
+
646
+ Idempotent: a community already claimed by a non-superseded domain (``communities``
647
+ contains its id) is skipped outright, and a computed slug colliding with an existing
648
+ non-superseded domain is also skipped (``store.find_domain_by_slug``) — never
649
+ reproposed, never suffixed against the store (suffixing only disambiguates
650
+ collisions WITHIN this run). Both skips fold into ``CollectStats.already_claimed``.
651
+
652
+ Within-run prefix dedup (live finding, see ``_dedupe_identical_prefix_sets``): when two
653
+ or more candidates surviving THIS run independently derive the IDENTICAL non-empty
654
+ ``path_prefixes`` set, only the one with the most anchorable members keeps it — the
655
+ rest still survive, just with ``path_prefixes=[]`` (title/summary/slug untouched). Two
656
+ domains carrying a stabilizer that matches the same code would let a later sync
657
+ REPLACE refresh point both at (some of) the same communities, the same
658
+ silently-overlapping-memory shape the breadth guard above exists to prevent.
659
+ """
660
+ labels = reader.community_labels()
661
+ claimed = _claimed_communities(store)
662
+ dir_breadth, total_communities = _prefix_community_breadth(reader)
663
+
664
+ stats = CollectStats()
665
+ # (community_id, anchorable_ids, god_node)
666
+ candidates: list[tuple[str, list[str], str | None]] = []
667
+ for community in reader.communities():
668
+ anchorable = _anchorable_member_ids(community.members, reader)
669
+ if len(anchorable) < min_members:
670
+ stats.below_threshold += 1
671
+ continue
672
+ if paths and not _matches_any_prefix(anchorable, paths, reader):
673
+ stats.filtered += 1
674
+ continue
675
+ candidates.append((community.community_id, anchorable, community.god_node))
676
+
677
+ candidates.sort(key=lambda c: _community_sort_key(c[0]))
678
+ # Free byproduct of the loop above (no per-candidate label/summary/path_prefixes work
679
+ # yet) -- the true significant-community count BEFORE `limit` truncates it (finding B).
680
+ stats.total_before_limit = len(candidates)
681
+ if limit is not None:
682
+ candidates = candidates[:limit]
683
+
684
+ # Pass 1: resolve which candidates actually survive (the "claimed" skip and within-run
685
+ # slug dedup are inherently order-dependent — `used_slugs` and
686
+ # `store.find_domain_by_slug` both need sequential state), building each survivor's
687
+ # slug/title/summary WITHOUT yet deriving path_prefixes.
688
+ used_slugs: set[str] = set()
689
+ pending: list[dict] = []
690
+ for community_id, anchorable, god_node_id in candidates:
691
+ if community_id in claimed:
692
+ stats.already_claimed += 1
693
+ continue
694
+
695
+ label = labels.get(community_id)
696
+ if label is not None and _label_names_other_community(label, community_id, reader):
697
+ label = None
698
+ # Belt-and-braces: community_labels() already drops blank-after-strip values, but
699
+ # a god node's name comes straight from graph.json with no such guard — a blank
700
+ # name must fall through to the next fallback rather than reach Domain.title,
701
+ # which raises (mid-run, after any earlier proposals in the batch already
702
+ # committed) on blank text.
703
+ god_name = None
704
+ if god_node_id is not None:
705
+ god_node = reader.get_node(god_node_id)
706
+ if god_node is not None and god_node.name.strip():
707
+ god_name = god_node.name
708
+
709
+ # Redact FIRST: title/slug both derive from this one clean value, never the raw
710
+ # label/god-node name — the engine can embed doc/rationale text a human typed, and
711
+ # slugify() alone doesn't scrub secrets, only unsafe slug characters (probe:
712
+ # "Auth (api_key=sk-live-...)" leaked the raw key into the repo-committed slug,
713
+ # and thus the `domain:<slug>` entity name, when slugify ran on unredacted text).
714
+ base_name = label or god_name or f"community-{community_id}"
715
+ title, _ = redact(base_name)
716
+ base_slug = slugify(title) or f"community-{community_id}"
717
+ slug = _dedupe_slug(base_slug, used_slugs)
718
+
719
+ existing = store.find_domain_by_slug(slug)
720
+ if existing is not None:
721
+ stats.already_claimed += 1
722
+ continue
723
+
724
+ raw_summary = label if label else _member_digest(anchorable, god_node_id, reader)
725
+ summary, _ = redact(raw_summary)
726
+
727
+ pending.append(
728
+ {
729
+ "community_id": community_id,
730
+ "anchorable": anchorable,
731
+ "god_node_id": god_node_id,
732
+ "slug": slug,
733
+ "title": title,
734
+ "summary": summary,
735
+ "has_label": label is not None,
736
+ }
737
+ )
738
+
739
+ # Pass 2: derive path_prefixes for every survivor, then dedupe an identical non-empty
740
+ # set derived by >= 2 of them down to the single largest-membership candidate (live
741
+ # finding — see `_dedupe_identical_prefix_sets`).
742
+ derived_prefixes = _dedupe_identical_prefix_sets(
743
+ pending, reader, dir_breadth, total_communities
744
+ )
745
+
746
+ # Pass 3: assemble the final DomainCandidate per survivor, same order as `pending`.
747
+ out: list[DomainCandidate] = []
748
+ for p in pending:
749
+ out.append(
750
+ DomainCandidate(
751
+ community_id=p["community_id"],
752
+ suggested_slug=p["slug"],
753
+ suggested_title=p["title"],
754
+ summary=p["summary"],
755
+ path_prefixes=derived_prefixes[p["community_id"]],
756
+ member_count=len(p["anchorable"]),
757
+ top_members=_top_member_names(p["anchorable"], reader),
758
+ top_file=_top_file(p["anchorable"], p["god_node_id"], reader),
759
+ has_label=p["has_label"],
760
+ anchor=_god_node_descriptor(p["god_node_id"], reader),
761
+ )
762
+ )
763
+
764
+ stats.total = len(out)
765
+ return out, stats
766
+
767
+
768
+ def bootstrap_domains(
769
+ store: Store,
770
+ reader: GraphifyReader,
771
+ *,
772
+ min_members: int = 5,
773
+ paths: list[str] | None = None,
774
+ dry_run: bool = False,
775
+ limit: int | None = None,
776
+ ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
777
+ ) -> BootstrapReport:
778
+ """Propose one ``Domain`` draft per significant community (§4.1, bootstrap path).
779
+
780
+ Selection is entirely delegated to ``collect_domain_candidates`` (see its docstring
781
+ for the full threshold/label/path_prefixes/redaction/idempotency rules); this
782
+ function's own job is only to turn each survivor into a ``Domain`` write (or, on
783
+ ``dry_run=True``, a listing entry) — the writing half of the bootstrap pipeline.
784
+
785
+ ``ratify_policy`` (default ``RatifyPolicy.MANUAL``): the resolved
786
+ ``SIDEGRAPH_RATIFY_POLICY`` value (design D1), sampled once by the CLI shell
787
+ immediately before this call and passed down unchanged. Under ``AUTO_ALL`` only (D3:
788
+ domains are never eligible under ``auto-low-risk``), each proposed domain's own
789
+ post-write block stamps it ``auto:auto-all`` via the shared ``_auto_ratify`` helper
790
+ when ``_domain_anchored`` and the rest of the D3 gate hold, then immediately resolves
791
+ its membership through the same ``sync.activate_accepted_domain`` helper the human
792
+ MCP/CLI ratify paths use — a bootstrap domain is not exempt from the one
793
+ ``ratify_domains`` gate, it can just clear it without a human tap. The once-per-batch
794
+ TOC rebuild happens after the whole loop, only when at least one domain was actually
795
+ auto-ratified.
796
+ # see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1/D2/D3
797
+ """
798
+ graph_version = reader.graph_version()
799
+ candidates, stats = collect_domain_candidates(
800
+ store, reader, min_members=min_members, paths=paths, limit=limit
801
+ )
802
+
803
+ report = BootstrapReport(
804
+ below_threshold=stats.below_threshold,
805
+ filtered=stats.filtered,
806
+ skipped_existing=stats.already_claimed,
807
+ total_before_limit=stats.total_before_limit,
808
+ )
809
+
810
+ for c in candidates:
811
+ # D7.4: computed regardless of dry_run -- a real run's proposals get the same
812
+ # lint a dry run's listing would have shown (the CLI prints report.warnings after
813
+ # either path; see cli._domains_bootstrap).
814
+ prefix_warnings = _lint_domain_path_prefixes(c.path_prefixes, reader, store)
815
+ if prefix_warnings:
816
+ report.warnings.append({"slug": c.suggested_slug, "warnings": prefix_warnings})
817
+
818
+ if dry_run:
819
+ report.proposed += 1
820
+ report.dry_run.append(
821
+ {
822
+ "community_id": c.community_id,
823
+ "slug": c.suggested_slug,
824
+ "title": c.suggested_title,
825
+ "summary": c.summary,
826
+ "path_prefixes": c.path_prefixes,
827
+ "warnings": prefix_warnings,
828
+ }
829
+ )
830
+ continue
831
+
832
+ domain = Domain(
833
+ slug=c.suggested_slug,
834
+ title=c.suggested_title,
835
+ summary=c.summary,
836
+ communities=[c.community_id],
837
+ path_prefixes=c.path_prefixes,
838
+ provenance=Provenance(
839
+ source="bootstrap",
840
+ author="sidegraph-domains",
841
+ graph_version=graph_version,
842
+ ),
843
+ )
844
+ store.add_domain(domain)
845
+ report.proposed += 1
846
+
847
+ # Auto-ratify (design D2/D3), auto-all only -- reuses `prefix_warnings`, already
848
+ # computed above for the dry-run listing too, rather than a second lint call.
849
+ if not dry_run and ratify_policy == RatifyPolicy.AUTO_ALL:
850
+ domain_anchored = _domain_anchored(
851
+ reader, prefix_warnings, domain.seed_anchors, domain.path_prefixes
852
+ )
853
+ signal = AutoEligibility(
854
+ kind="domain",
855
+ live_tier12=0,
856
+ ambiguous_or_orphan_only=True,
857
+ pipeline_clean=True,
858
+ has_provenance=True,
859
+ domain_anchored=domain_anchored,
860
+ has_supersedes=False,
861
+ )
862
+ if auto_ratify_eligible(signal, ratify_policy):
863
+ outcome = _auto_ratify(store, domain.domain_id, "domain", ratify_policy)
864
+ auto_ratify_error = outcome.error
865
+ if outcome.ratified_by is not None:
866
+ report.auto_ratified += 1
867
+ # Ruling R (design D2/D6 checkpoint-2 fix): the domain transition
868
+ # already committed by this point, so an activation failure must
869
+ # report and continue, never abort the batch -- mirrors
870
+ # capture.py's own domain auto-block; sync.activate_accepted_domain
871
+ # itself stays unchanged (its human MCP/CLI callers carry the
872
+ # identical unprotected stale-marker write).
873
+ try:
874
+ activation = activate_accepted_domain(domain, store, reader)
875
+ except Exception as e:
876
+ auto_ratify_error = f"activation: {e}"
877
+ else:
878
+ # `resolved` and `overbroad` are independent fields on
879
+ # `sync.DomainActivation` (checked separately, not elif'd) --
880
+ # rev 12 erratum: a claim-cap rejection used to report a clean
881
+ # success here, while the human MCP/CLI wrappers (server.py,
882
+ # cli.py) rendered their own "path rule too broad" sentence for
883
+ # the identical outcome -- mirrors capture.py's own domain
884
+ # auto-block (same sentence, same literal
885
+ # `sidegraph:heal-anchors` trigger phrase).
886
+ if not activation.resolved:
887
+ auto_ratify_error = f"activation: {activation.error}"
888
+ if activation.overbroad is not None:
889
+ prefixes = ", ".join(repr(p) for p in domain.path_prefixes)
890
+ auto_ratify_error = (
891
+ f"activation: path rule too broad: {prefixes} match "
892
+ f"{activation.overbroad['matched']}/"
893
+ f"{activation.overbroad['total']} communities — not "
894
+ "applied; seed_anchors, if any, still applied"
895
+ )
896
+ if auto_ratify_error is not None:
897
+ report.auto_ratify_failures.append(f"{domain.domain_id}: {auto_ratify_error}")
898
+
899
+ if report.auto_ratified > 0:
900
+ store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store)))
901
+
902
+ return report