codegraph-brain 0.6.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.
Files changed (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,470 @@
1
+ """Auto-propose a starter patterns.yaml from the measured graph (#174).
2
+
3
+ Measure-then-label: discover candidate domains from FQN structure, fit each
4
+ against the bundled pattern templates by scoring with the existing
5
+ DriftScorer, and emit a ready-to-edit ontology whose tolerances are the
6
+ measured values plus a margin — green by construction on the same graph.
7
+ """
8
+
9
+ import math
10
+ import tempfile
11
+ from collections import Counter
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from cgis.core.models import VIRTUAL_FILE_PATH, Node
17
+ from cgis.query.drift.drift import DomainConfig, DriftScorer
18
+ from cgis.query.drift.fingerprint import FingerprintExtractor, PatternFingerprint
19
+ from cgis.storage.sqlite_store import SQLiteStore
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Bundled default ontology header (version/profiles/patterns/hygiene only).
23
+ # project_domains and project_level are intentionally excluded — those are
24
+ # the generated output. The staleness pin in the test suite guards this block
25
+ # against drift from docs/ontology/patterns.yaml.
26
+ # ---------------------------------------------------------------------------
27
+
28
+ _DEFAULT_ONTOLOGY_HEADER = """\
29
+ # Generated by `cgis init-ontology` — a measured baseline, not a verdict.
30
+ # Tolerances are measured + margin: ratchet them DOWN over time.
31
+ # Docs: docs/specs/2026-06-12-init-ontology-design.md
32
+ version: "2.1.0"
33
+
34
+ # ── Measurement profiles (spec §2.3, §3.3) ───────────────────────────────────
35
+ # drift_weights: v1 per-component weights — now used ONLY for the gate term
36
+ # (hygiene + template-declared non-triad constraints).
37
+ # layers: fingerprint v2 layer weights (imports TV / calls TV / gates),
38
+ # must sum to 1.0. The calls layer is discounted by (1 - unresolved_ratio);
39
+ # a layer with zero connected triples is excluded and the rest renormalize.
40
+ # triad_weights: per-triad w_i for the TV metric; unlisted triads weigh 1.0.
41
+ profiles:
42
+ python:
43
+ drift_weights:
44
+ hub_count: 0.15
45
+ star_count: 0.15
46
+ chain_len: 0.10
47
+ dag_depth: 0.10
48
+ router_count: 0.10
49
+ cycle_ratio: 0.25
50
+ unresolved_ratio: 0.15
51
+ layers:
52
+ imports: 0.35
53
+ calls: 0.35
54
+ gates: 0.30
55
+ triad_weights: {}
56
+ typescript:
57
+ drift_weights:
58
+ hub_count: 0.10
59
+ star_count: 0.10
60
+ chain_len: 0.10
61
+ dag_depth: 0.15
62
+ router_count: 0.10
63
+ cycle_ratio: 0.30
64
+ unresolved_ratio: 0.15
65
+ layers:
66
+ imports: 0.45
67
+ calls: 0.25
68
+ gates: 0.30
69
+ triad_weights: {}
70
+
71
+ # ── Global hygiene invariants (spec §2.1) — hard gates, never distances ──────
72
+ hygiene:
73
+ cycle_ratio: {max: 0.0}
74
+ unresolved_ratio: {max: 0.2}
75
+ tangle_ratio: {max: 0.25}
76
+
77
+ # ── The closed alphabet: five templates (spec §2.1, §3.3) ────────────────────
78
+ # ideal: hand-authored points in 13-triad space, one per layer, each summing
79
+ # to 1.0. Counting constraints (hub/star/chain/router) are GONE — topology is
80
+ # measured as TV distance to the ideal. Non-triad structural gates (dag_depth)
81
+ # and template-specific hygiene remain as constraints.
82
+ patterns:
83
+ pure_utility:
84
+ description: "Shared library called by many; depends on nothing"
85
+ ideal:
86
+ imports: {"021U": 1.0}
87
+ calls: {"021U": 1.0}
88
+ unresolved_ratio: {max: 0.1}
89
+
90
+ pipeline_stage:
91
+ description: "Sequential transformer — one input domain, one output domain"
92
+ ideal:
93
+ imports: {"021C": 1.0}
94
+ calls: {"021C": 1.0}
95
+
96
+ orchestrator:
97
+ description: "Coordinates N independent services; no leaf-to-leaf edges"
98
+ ideal:
99
+ imports: {"021D": 1.0}
100
+ calls: {"021D": 1.0}
101
+
102
+ layered_dag:
103
+ description: "Clean layered architecture; no upward dependencies"
104
+ params:
105
+ min_depth: 3
106
+ ideal:
107
+ imports: {"021D": 0.5, "021C": 0.5}
108
+ calls: {"021D": 0.5, "021C": 0.5}
109
+ dag_depth: {min: $min_depth}
110
+
111
+ dispatcher:
112
+ description: "Routes to the first matching strategy; mutually exclusive paths"
113
+ ideal:
114
+ imports: {"021D": 1.0}
115
+ calls: {"021D": 1.0}
116
+
117
+ funnel:
118
+ # Transpose of layered_dag — fan-in with internal staging (#186).
119
+ description: "Convergent aggregation — many inputs funnel through staged sinks"
120
+ ideal:
121
+ imports: {"021U": 0.5, "021C": 0.5}
122
+ calls: {"021U": 0.5, "021C": 0.5}
123
+
124
+ """
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Constants
128
+ # ---------------------------------------------------------------------------
129
+
130
+ _NO_FIT_THRESHOLD = 0.5
131
+ _TS_EXTENSIONS = frozenset({".ts", ".tsx", ".js", ".jsx", ".vue"})
132
+
133
+ # Parse the bundled header once at module load (used by _baseline_lines for the
134
+ # hygiene block; template ranking now lives on DriftScorer.fit_templates).
135
+ _PARSED_HEADER: dict[str, object] = yaml.safe_load(_DEFAULT_ONTOLOGY_HEADER)
136
+
137
+ # Commented-out project_level skeleton appended after project_domains.
138
+ _PROJECT_LEVEL_SKELETON = """\
139
+ # project_level: # quotient binding is an architectural decision — uncomment and tune:
140
+ # - name: "whole"
141
+ # fqn_prefix: "<collapse-prefix>"
142
+ # expected_pattern: pipeline_stage
143
+ # profile: python
144
+ # drift_tolerance: 0.50
145
+ # enforce: false
146
+ """
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Helper functions
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def _in_prefix(fqn: str, prefix: str) -> bool:
155
+ """Return True iff fqn equals prefix or is a child of it (segment-boundary-safe)."""
156
+ return fqn == prefix or fqn.startswith(prefix + ".")
157
+
158
+
159
+ def _detect_profile(domain_nodes: list[Node]) -> tuple[str, bool]:
160
+ """Return (profile, guessed): majority file extension decides the profile.
161
+
162
+ Maps ``.py`` → ``python``; ``.ts``/``.tsx``/``.js``/``.jsx``/``.vue`` →
163
+ ``typescript``. Ties or unknown extensions fall back to ``("python", True)``
164
+ with a ``# profile guessed`` annotation.
165
+ """
166
+ py_count = 0
167
+ ts_count = 0
168
+ for n in domain_nodes:
169
+ ext = Path(n.file_path).suffix.lower()
170
+ if ext == ".py":
171
+ py_count += 1
172
+ elif ext in _TS_EXTENSIONS:
173
+ ts_count += 1
174
+
175
+ if py_count > ts_count:
176
+ return "python", False
177
+ if ts_count > py_count:
178
+ return "typescript", False
179
+ return "python", True
180
+
181
+
182
+ def _fit_templates(
183
+ fp: PatternFingerprint,
184
+ profile: str,
185
+ scorer: DriftScorer,
186
+ ) -> list[tuple[str, float]]:
187
+ """Return ``[(template_name, fit_score)]`` sorted by (score, name).
188
+
189
+ Thin wrapper over ``DriftScorer.fit_templates`` — the canonical
190
+ distance-to-template, shared with fit-quality reporting (#177) so both
191
+ surfaces rank templates identically.
192
+ """
193
+ return scorer.fit_templates(fp, profile)
194
+
195
+
196
+ def _ceil2(x: float) -> float:
197
+ """Ceil x to 2 decimal places (tolerance rounding — always rounds up)."""
198
+ return math.ceil(x * 100) / 100
199
+
200
+
201
+ def _floor2(x: float) -> float:
202
+ """Floor x to 2 decimal places (min-constraint baseline rounding — always rounds down)."""
203
+ return math.floor(x * 100) / 100
204
+
205
+
206
+ def _hygiene_score(
207
+ fp: PatternFingerprint, prefix: str, scorer: DriftScorer, profile: str | None
208
+ ) -> float:
209
+ """Return the hygiene drift score for a domain scored with the given profile.
210
+
211
+ Used to set tolerances for hygiene-only entries. The profile MUST match the
212
+ one emitted into the YAML (detected by ``_detect_profile``): if ``profile``
213
+ differs from the emitted value the v1 drift_weights used to score
214
+ ``cycle_ratio`` and ``unresolved_ratio`` will diverge, breaking the
215
+ round-trip guarantee (spec §4.3) on domains that have cycles.
216
+ """
217
+ cfg = DomainConfig(
218
+ name=prefix,
219
+ fqn_prefix=prefix,
220
+ expected_pattern=None,
221
+ profile=profile,
222
+ drift_tolerance=1.0,
223
+ )
224
+ return scorer.score(fp, cfg).drift_score
225
+
226
+
227
+ def _baseline_lines(
228
+ fp: PatternFingerprint,
229
+ hygiene: dict[str, object] | None = None,
230
+ ) -> list[str]:
231
+ """Return YAML lines for a ``hygiene_baseline:`` block, if any key breaches its global bound.
232
+
233
+ For each hygiene key, if the measured fingerprint value breaches the GLOBAL bound
234
+ (max → measured > bound; min → measured < bound; exact → skipped entirely),
235
+ the key is collected with an operator-correct rounded value.
236
+
237
+ Rounding is operator-aware (spec §2.2, #221 review):
238
+ - ``max`` constraints: round UP via ``_ceil2`` — a floored value below the true
239
+ measurement would gate_fail the proposal on its own graph (the round-trip guarantee).
240
+ - ``min`` constraints: round DOWN via ``_floor2`` — a ceiled value above the true
241
+ measurement would gate_fail the proposal on its own graph.
242
+ - ``exact`` constraints: SKIPPED — an exact-bound acknowledgment would pin the value
243
+ and re-fail on improvement; exact breaches remain visible as gate_failed in the
244
+ proposal and cannot be acknowledged by init-ontology by design.
245
+
246
+ ``hygiene``: optional parsed hygiene mapping (for testing); defaults to the bundled
247
+ ``_PARSED_HEADER["hygiene"]`` block when not supplied.
248
+ Returns an empty list when no key breaches its bound.
249
+ """
250
+ if hygiene is None:
251
+ _hygiene_obj = _PARSED_HEADER.get("hygiene") or {}
252
+ hygiene = _hygiene_obj if isinstance(_hygiene_obj, dict) else {}
253
+ items: list[str] = []
254
+ for key, constraint in hygiene.items():
255
+ if not isinstance(constraint, dict):
256
+ continue
257
+ measured = float(getattr(fp, key, 0.0))
258
+ if "max" in constraint and measured > float(constraint["max"]):
259
+ rounded = _ceil2(measured)
260
+ items.append(
261
+ f" {key}: {rounded}"
262
+ " # acknowledged at baseline by init-ontology — ratchet down over time"
263
+ )
264
+ elif "min" in constraint and measured < float(constraint["min"]):
265
+ rounded = _floor2(measured)
266
+ items.append(
267
+ f" {key}: {rounded}"
268
+ " # acknowledged at baseline by init-ontology — ratchet down over time"
269
+ )
270
+ # exact: skipped — pins value and would re-fail on improvement; unacknowledgeable by design.
271
+ return items
272
+
273
+
274
+ def _domain_entry(
275
+ prefix: str,
276
+ all_nodes: list[Node],
277
+ extractor: FingerprintExtractor,
278
+ scorer: DriftScorer,
279
+ margin: float,
280
+ min_nodes: int,
281
+ name_is_unique: bool,
282
+ ) -> str:
283
+ """Return the YAML block string for one project domain entry.
284
+
285
+ Decision ladder (spec §2.1.5):
286
+ 1. node_count < min_nodes → hygiene-only with census-too-small comment.
287
+ 2. edge_count == 0 → hygiene-only with no-intra-domain-edges comment.
288
+ 3. best fit > _NO_FIT_THRESHOLD → hygiene-only with no-template-fits comment.
289
+ 4. Otherwise → labeled with expected_pattern, tolerance, and runner-up comment.
290
+
291
+ Tolerances for hygiene-only entries use the v1 hygiene score + margin.
292
+ Tolerances for labeled entries use the best-fit score + margin.
293
+ """
294
+ fp = extractor.extract(prefix)
295
+ domain_nodes = [
296
+ n for n in all_nodes if _in_prefix(n.id, prefix) and n.file_path != VIRTUAL_FILE_PATH
297
+ ]
298
+ profile, guessed = _detect_profile(domain_nodes)
299
+ fits = _fit_templates(fp, profile, scorer)
300
+ best_name, best = fits[0]
301
+ runner_name, runner = fits[1] if len(fits) > 1 else (fits[0][0], fits[0][1])
302
+
303
+ # name: last segment, or full prefix when it collides.
304
+ short_name = prefix.rsplit(".", maxsplit=1)[-1]
305
+ entry_name = short_name if name_is_unique else prefix
306
+
307
+ lines = [f' - name: "{entry_name}"', f' fqn_prefix: "{prefix}"']
308
+
309
+ profile_suffix = " # profile guessed" if guessed else ""
310
+
311
+ # Hygiene-only reason, or None when the domain earns a template label.
312
+ if fp.node_count < min_nodes:
313
+ reason = f"# below min_nodes ({fp.node_count} nodes) — census too small to label"
314
+ elif fp.edge_count == 0:
315
+ reason = "# no intra-domain edges — nothing to fit"
316
+ elif best > _NO_FIT_THRESHOLD:
317
+ reason = f"# no template fits (best: {best_name} at {best:.2f})"
318
+ else:
319
+ reason = None
320
+
321
+ if reason is not None:
322
+ tolerance = _ceil2(_hygiene_score(fp, prefix, scorer, profile) + margin)
323
+ comment = reason
324
+ else:
325
+ tolerance = _ceil2(best + margin)
326
+ comment = (
327
+ f"# measured ≈ {best:.2f} via init-ontology"
328
+ f" (runner-up: {runner_name} at {runner:.2f}) — ratchet down over time"
329
+ )
330
+ lines.append(f" expected_pattern: {best_name}")
331
+ lines.append(f" profile: {profile}{profile_suffix}")
332
+ lines.append(f" drift_tolerance: {tolerance:.2f} {comment}")
333
+
334
+ # Emit hygiene_baseline for any measured value that breaches the GLOBAL hygiene bound
335
+ # (spec §2.2, #176/#170 task 4). Applies to BOTH labeled and hygiene-only entries.
336
+ baseline_items = _baseline_lines(fp)
337
+ if baseline_items:
338
+ lines.append(" hygiene_baseline:")
339
+ lines.extend(baseline_items)
340
+
341
+ return "\n".join(lines)
342
+
343
+
344
+ def _assemble_yaml(entries: list[str]) -> str:
345
+ """Concatenate the bundled header, generated project_domains, and skeleton comment."""
346
+ domain_block = "project_domains:\n" + "\n".join(entries) + "\n"
347
+ return _DEFAULT_ONTOLOGY_HEADER + domain_block + "\n" + _PROJECT_LEVEL_SKELETON
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # Public API
352
+ # ---------------------------------------------------------------------------
353
+
354
+
355
+ def discover_domains(nodes: list[Node], depth: int | None = None) -> list[str]:
356
+ """Candidate domain prefixes from node FQNs (spec §2.1).
357
+
358
+ Auto-descent: walk down from the FQN roots while a level has a single
359
+ child; the first level with >= 2 children yields the candidates. An
360
+ explicit ``depth`` (segment count, >= 1) overrides auto-descent. Virtual
361
+ boundary nodes are excluded. Sorted, deduplicated.
362
+
363
+ A never-branching lineage yields its deepest id as the single candidate;
364
+ downstream min_nodes filtering keeps such micro-domains hygiene-only.
365
+
366
+ Raises:
367
+ ValueError: if ``depth`` is not ``None`` and is <= 0.
368
+ """
369
+ real_ids = [n.id for n in nodes if n.file_path != VIRTUAL_FILE_PATH]
370
+ if not real_ids:
371
+ return []
372
+ if depth is not None:
373
+ if depth <= 0:
374
+ msg = "depth must be a positive integer"
375
+ raise ValueError(msg)
376
+ return sorted(
377
+ {".".join(i.split(".")[:depth]) for i in real_ids if i.count(".") >= depth - 1}
378
+ )
379
+ prefix = ""
380
+ while True:
381
+ children = {
382
+ i[len(prefix) :].split(".")[0]
383
+ for i in real_ids
384
+ if i.startswith(prefix) and len(i) > len(prefix)
385
+ }
386
+ if not children:
387
+ # Never-branching lineage: the deepest id on this chain is the
388
+ # single candidate (downstream min_nodes will hygiene-fy it).
389
+ stub = prefix.removesuffix(".")
390
+ return [stub] if stub in set(real_ids) else []
391
+ if len(children) != 1:
392
+ break
393
+ prefix = f"{prefix}{next(iter(children))}."
394
+ return sorted({f"{prefix}{seg}" for seg in children})
395
+
396
+
397
+ def propose_ontology(
398
+ db_path: str,
399
+ margin: float = 0.03,
400
+ min_nodes: int = 10,
401
+ depth: int | None = None,
402
+ ) -> str:
403
+ """Return a ready-to-edit patterns.yaml proposed from the measured graph.
404
+
405
+ Measure-then-label: discover candidate domain prefixes via ``discover_domains``,
406
+ fingerprint each with ``FingerprintExtractor``, fit against the five bundled
407
+ templates using ``DriftScorer`` (zero new fitting math), and emit YAML whose
408
+ ``drift_tolerance`` values equal the measured score plus ``margin`` — green by
409
+ construction when fed back to ``analyze_drift`` on the same graph.
410
+
411
+ Args:
412
+ db_path: Path to the SQLite graph database produced by ``cgis ingest``.
413
+ margin: Headroom added to every measured score when computing
414
+ ``drift_tolerance`` (default 0.03).
415
+ min_nodes: Domains with fewer real nodes than this threshold are
416
+ emitted as hygiene-only (no ``expected_pattern``).
417
+ depth: Fixed FQN segment depth for domain discovery; ``None`` triggers
418
+ auto-descent.
419
+
420
+ Returns:
421
+ YAML text — bundled header (profiles/patterns/hygiene) followed by
422
+ a generated ``project_domains:`` block.
423
+
424
+ Raises:
425
+ FileNotFoundError: if ``db_path`` does not exist (SQLite would
426
+ otherwise silently create an empty database).
427
+ """
428
+ if not Path(db_path).is_file():
429
+ msg = f"Graph database not found: {db_path}. Run `cgis ingest` first."
430
+ raise FileNotFoundError(msg)
431
+
432
+ # Write the bundled header to a temp file so DriftScorer can load it
433
+ # (its constructor strictly requires a path; we keep its surface unchanged).
434
+ # delete=False: the file persists after the context manager closes it so
435
+ # DriftScorer can read it; we unlink it explicitly in the finally block.
436
+ with tempfile.NamedTemporaryFile(
437
+ mode="w", suffix=".yaml", delete=False, encoding="utf-8"
438
+ ) as header_fd:
439
+ header_fd.write(_DEFAULT_ONTOLOGY_HEADER)
440
+ header_path = header_fd.name
441
+ try:
442
+ scorer = DriftScorer(header_path)
443
+
444
+ with SQLiteStore(db_path) as store:
445
+ all_nodes = store.get_all_nodes()
446
+ extractor = FingerprintExtractor(store)
447
+
448
+ prefixes = discover_domains(all_nodes, depth=depth)
449
+
450
+ # Detect name collisions: short names (last segment) that appear
451
+ # more than once across prefixes must use the full prefix as name:.
452
+ short_names = [p.rsplit(".", maxsplit=1)[-1] for p in prefixes]
453
+ name_counts = Counter(short_names)
454
+
455
+ entries = [
456
+ _domain_entry(
457
+ prefix,
458
+ all_nodes,
459
+ extractor,
460
+ scorer,
461
+ margin,
462
+ min_nodes,
463
+ name_is_unique=(name_counts[prefix.rsplit(".", maxsplit=1)[-1]] == 1),
464
+ )
465
+ for prefix in prefixes
466
+ ]
467
+ finally:
468
+ Path(header_path).unlink(missing_ok=True)
469
+
470
+ return _assemble_yaml(entries)
@@ -0,0 +1,75 @@
1
+ """Domain-quotient graph: collapse each domain to one node (spec §3.4).
2
+
3
+ The quotient is scored by the SAME FingerprintExtractor + DriftScorer as
4
+ module-level domains — that closure under coarsening is the point, not an
5
+ implementation convenience.
6
+ """
7
+
8
+ from collections import Counter
9
+
10
+ from cgis.core.models import Edge, EdgeType, Node, NodeType
11
+ from cgis.query.drift.drift import DomainConfig
12
+
13
+ #: FQN prefix of quotient nodes; the project_level binding matches it.
14
+ QUOTIENT_PREFIX = "quotient"
15
+
16
+ _QUOTIENT_EDGE_TYPES = frozenset({EdgeType.IMPORTS, EdgeType.CALLS})
17
+
18
+
19
+ def build_quotient(
20
+ nodes: list[Node], edges: list[Edge], domains: list[DomainConfig]
21
+ ) -> tuple[list[Node], list[Edge]]:
22
+ """Return (quotient_nodes, quotient_edges) for the given domain bindings.
23
+
24
+ One MODULE node per domain (id = quotient.<name>); cross-domain IMPORTS
25
+ and CALLS edges aggregate per (source domain, target domain, type) with
26
+ weight = aggregated edge count. Intra-domain edges and edges touching
27
+ nodes outside every domain are dropped (raw_call targets land here, so
28
+ the quotient's unresolved_ratio is 0 for the observe-only milestone —
29
+ recorded in tests/self_parsing/test_drift.py).
30
+ """
31
+ # Longest-prefix match: if one domain's prefix nests inside another's,
32
+ # the most specific binding wins regardless of declaration order.
33
+ by_specificity = sorted(domains, key=lambda d: len(d.fqn_prefix), reverse=True)
34
+ domain_of: dict[str, str] = {}
35
+ for n in nodes:
36
+ for d in by_specificity:
37
+ if n.id == d.fqn_prefix or n.id.startswith(d.fqn_prefix + "."):
38
+ domain_of[n.id] = d.name
39
+ break
40
+
41
+ qnodes = [
42
+ Node(
43
+ id=f"{QUOTIENT_PREFIX}.{d.name}",
44
+ type=NodeType.MODULE,
45
+ name=d.name,
46
+ file_path=d.fqn_prefix,
47
+ start_line=0,
48
+ end_line=0,
49
+ )
50
+ for d in domains
51
+ ]
52
+
53
+ counts: Counter[tuple[str, str, EdgeType]] = Counter(
54
+ (domain_of[e.source], domain_of[e.target], e.type)
55
+ for e in edges
56
+ if e.type in _QUOTIENT_EDGE_TYPES
57
+ and e.source in domain_of
58
+ and e.target in domain_of
59
+ and domain_of[e.source] != domain_of[e.target]
60
+ )
61
+
62
+ qedges = [
63
+ Edge(
64
+ id=f"{QUOTIENT_PREFIX}.{src}:{etype.value}:{QUOTIENT_PREFIX}.{dst}",
65
+ source=f"{QUOTIENT_PREFIX}.{src}",
66
+ target=f"{QUOTIENT_PREFIX}.{dst}",
67
+ type=etype,
68
+ weight=float(count),
69
+ confidence=1.0,
70
+ )
71
+ for (src, dst, etype), count in sorted(
72
+ counts.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2].value)
73
+ )
74
+ ]
75
+ return qnodes, qedges