gabriel / muse public
shard.py python
479 lines 16.7 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 146 days ago
1 """``muse coord shard`` — partition a codebase into low-coupling work zones.
2
3 Divides files into N groups (shards) such that files within each shard are
4 tightly coupled to each other (many imports between them) and loosely coupled
5 to files in other shards. Each shard is a safe parallel-work zone for agents.
6
7 Agents assigned to different shards are unlikely to create merge conflicts
8 because they work on different parts of the dependency graph.
9
10 Algorithm
11 ---------
12 1. Build the import graph from the committed snapshot.
13 2. Compute connected components of the import graph.
14 3. Distribute components greedily into N shards, balancing by symbol count.
15 4. Report each shard with its files, symbol count, and coupling score.
16
17 The coupling score is the count of cross-shard import edges (lower = better).
18
19 Usage::
20
21 muse coord shard --agents 4
22 muse coord shard --agents 8 --commit HEAD~10
23 muse coord shard --agents 4 --language Python
24 muse coord shard --agents 4 --json
25
26 Output (text)::
27
28 Shard plan — 4 agents, commit a1b2c3d4
29 ──────────────────────────────────────────────────────────────
30
31 Shard 1 (12 symbols, 3 files, coupling 1):
32 src/billing.py
33 src/billing_utils.py
34 src/tax.py
35
36 Shard 2 (8 symbols, 2 files, coupling 0):
37 src/auth.py
38 src/session.py
39 ...
40
41 Cross-shard edges: 1
42
43 JSON output schema::
44
45 {
46 "schema_version": str,
47 "commit": str, // 8-char short ID (display only)
48 "full_commit_id": str, // full commit ID for cross-referencing
49 "agents": int,
50 "shards_created": int,
51 "total_files": int,
52 "total_symbols": int,
53 "cross_shard_edges": int,
54 "shards": [
55 {
56 "shard": int,
57 "files": [str, ...],
58 "symbol_count": int,
59 "coupling_score": int
60 },
61 ...
62 ],
63 "duration_ms": float
64 }
65
66 Flags:
67
68 ``--agents N``
69 Number of parallel work zones to create (1–256, default: 4).
70
71 ``--commit, -c REF``
72 Use a historical snapshot instead of HEAD.
73
74 ``--language LANG``
75 Restrict to files of this language.
76
77 ``--format`` / ``--json``
78 Emit the shard plan as compact JSON.
79
80 Exit codes::
81
82 0 — success (includes no-files-found case)
83 1 — bad arguments (--agents out of range) or commit not found
84 """
85
86 from __future__ import annotations
87
88 import argparse
89 import json
90 import logging
91 import pathlib
92 import sys
93
94 from muse._version import __version__
95 from muse.core._types import short_id
96 from muse.core.errors import ExitCode
97 from muse.core.object_store import read_object
98 from muse.core.repo import read_repo_id, require_repo
99 from muse.core.store import Manifest, get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
100 from muse.plugins.code._query import language_of, symbols_for_snapshot
101 from muse.plugins.code.ast_parser import parse_symbols
102 from muse.core.validation import sanitize_display
103 from muse.core.timing import start_timer
104
105 type _AdjMap = dict[str, set[str]]
106 type _CounterMap = dict[str, int]
107 type _ShardMap = dict[str, int]
108
109 logger = logging.getLogger(__name__)
110
111 # ── Input constraints ─────────────────────────────────────────────────────────
112
113 #: Allowed range for ``--agents``.
114 _MIN_AGENTS: int = 1
115 _MAX_AGENTS: int = 256
116
117
118 # ── Error helper ──────────────────────────────────────────────────────────────
119
120
121 def _err(msg: str, as_json: bool, status: str = "error") -> None:
122 """Print an error and return. Caller raises SystemExit."""
123 if as_json:
124 print(json.dumps({"error": msg, "status": status}))
125 else:
126 print(f"❌ {msg}", file=sys.stderr)
127
128
129 # ── Internal helpers ──────────────────────────────────────────────────────────
130
131
132 def _file_stem(fp: str) -> str:
133 return pathlib.PurePosixPath(fp).stem
134
135
136 def _build_import_edges(
137 root: pathlib.Path,
138 manifest: Manifest,
139 language_filter: str | None,
140 ) -> list[tuple[str, str]]:
141 """Return ``(importer, importee)`` file-path pairs from the snapshot.
142
143 Reads each file's object from the object store, parses its import symbols,
144 then resolves each imported name to a file path in the manifest via a
145 stem-to-path lookup table.
146
147 Only files matching *language_filter* (when provided) are included.
148 Files whose object is missing from the store are silently skipped.
149
150 Args:
151 root: Repository root.
152 manifest: Snapshot file-path → object-ID mapping.
153 language_filter: Language name to restrict to, or ``None`` for all.
154
155 Returns:
156 List of ``(importer_path, importee_path)`` tuples.
157 """
158 stem_to_file: Manifest = {}
159 for fp in manifest:
160 if language_filter and language_of(fp) != language_filter:
161 continue
162 stem_to_file[_file_stem(fp)] = fp
163
164 edges: list[tuple[str, str]] = []
165 for file_path, obj_id in sorted(manifest.items()):
166 if language_filter and language_of(file_path) != language_filter:
167 continue
168 raw = read_object(root, obj_id)
169 if raw is None:
170 continue
171 tree = parse_symbols(raw, file_path)
172 for rec in tree.values():
173 if rec["kind"] != "import":
174 continue
175 imported = rec["qualified_name"].split(".")[-1].replace("import::", "")
176 target = stem_to_file.get(imported)
177 if target and target != file_path:
178 edges.append((file_path, target))
179 return edges
180
181
182 def _connected_components(
183 files: list[str],
184 edges: list[tuple[str, str]],
185 ) -> list[frozenset[str]]:
186 """Return weakly-connected components of the import graph.
187
188 Uses an iterative DFS to avoid stack overflow on large graphs.
189
190 Args:
191 files: All file paths (graph nodes).
192 edges: ``(importer, importee)`` pairs (directed edges treated as
193 undirected for connectivity purposes).
194
195 Returns:
196 List of :class:`frozenset` — one per connected component.
197 """
198 adj: _AdjMap = {f: set() for f in files}
199 for a, b in edges:
200 adj.setdefault(a, set()).add(b)
201 adj.setdefault(b, set()).add(a)
202
203 visited: set[str] = set()
204 components: list[frozenset[str]] = []
205
206 for start in files:
207 if start in visited:
208 continue
209 component: set[str] = set()
210 stack = [start]
211 while stack:
212 node = stack.pop()
213 if node in visited:
214 continue
215 visited.add(node)
216 component.add(node)
217 for neighbour in adj.get(node, set()):
218 if neighbour not in visited:
219 stack.append(neighbour)
220 components.append(frozenset(component))
221
222 return components
223
224
225 def _greedy_partition(
226 components: list[frozenset[str]],
227 sym_counts: _CounterMap,
228 n_shards: int,
229 ) -> list[frozenset[str]]:
230 """Distribute connected components into *n_shards* shards.
231
232 Uses a greedy largest-first strategy: components are sorted by total
233 symbol count (descending) and each is assigned to the shard with the
234 currently smallest symbol total. This approximates the Longest Processing
235 Time (LPT) algorithm for makespan minimisation.
236
237 Args:
238 components: Connected components to distribute.
239 sym_counts: File-path → symbol count mapping.
240 n_shards: Number of shards to create.
241
242 Returns:
243 List of *n_shards* :class:`frozenset` objects (some may be empty when
244 there are fewer components than shards).
245 """
246 shards: list[set[str]] = [set() for _ in range(n_shards)]
247 shard_sizes: list[int] = [0] * n_shards
248
249 # Sort components largest-first for better balance (LPT heuristic).
250 sorted_comps = sorted(
251 components,
252 key=lambda c: sum(sym_counts.get(f, 0) for f in c),
253 reverse=True,
254 )
255 for comp in sorted_comps:
256 smallest = min(range(n_shards), key=lambda i: shard_sizes[i])
257 shards[smallest].update(comp)
258 shard_sizes[smallest] += sum(sym_counts.get(f, 0) for f in comp)
259
260 return [frozenset(s) for s in shards]
261
262
263 # ── CLI registration ──────────────────────────────────────────────────────────
264
265
266 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
267 """Register the ``shard`` subcommand on *subparsers* (under ``muse coord``).
268
269 Wires all flags with their defaults, choices, and help text so that
270 ``--help`` output is accurate. Sets ``func`` to :func:`run`.
271 """
272 parser = subparsers.add_parser(
273 "shard",
274 help="Partition the codebase into N low-coupling work zones for parallel agents.",
275 description=__doc__,
276 formatter_class=argparse.RawDescriptionHelpFormatter,
277 )
278 parser.add_argument(
279 "--agents", "-n",
280 type=int,
281 default=4,
282 metavar="N",
283 help=f"Number of work zones ({_MIN_AGENTS}–{_MAX_AGENTS}, default: 4).",
284 )
285 parser.add_argument(
286 "--commit", "-c",
287 dest="ref",
288 default=None,
289 metavar="REF",
290 help="Use this commit instead of HEAD.",
291 )
292 parser.add_argument(
293 "--language", "-l",
294 default=None,
295 metavar="LANG",
296 help="Restrict to files of this language.",
297 )
298 parser.add_argument(
299 "--format", "-f",
300 default="text",
301 dest="fmt",
302 choices=("text", "json"),
303 help="Output format: text (default) or json.",
304 )
305 parser.add_argument(
306 "--json",
307 action="store_const",
308 const="json",
309 dest="fmt",
310 help="Shorthand for --format json.",
311 )
312 parser.set_defaults(func=run)
313
314
315 # ── Command implementation ────────────────────────────────────────────────────
316
317
318 def run(args: argparse.Namespace) -> None:
319 """Partition the codebase into N low-coupling work zones for parallel agents.
320
321 Uses the import graph connectivity to group files that are tightly coupled
322 together and loosely coupled to other shards. Agents assigned to different
323 shards minimise merge conflicts.
324
325 Execution order
326 ---------------
327 1. **Validate inputs** — ``--agents`` range checked before any file I/O.
328 Failure exits :attr:`~muse.core.errors.ExitCode.USER_ERROR` (1) with a
329 message on *stderr* or compact JSON on *stdout* when ``--format json``.
330 2. **Resolve repo** — :func:`~muse.core.repo.require_repo`.
331 3. **Resolve commit** — :func:`~muse.core.store.resolve_commit_ref`.
332 4. **Load symbol map** — :func:`~muse.plugins.code._query.symbols_for_snapshot`.
333 5. **Build import graph** — :func:`_build_import_edges` reads each file's
334 object from the store and parses import symbols.
335 6. **Partition** — :func:`_connected_components` then :func:`_greedy_partition`.
336 7. **Emit output** — compact JSON or human-readable text.
337
338 Security
339 --------
340 * ``--agents`` is validated against :data:`_MIN_AGENTS` / :data:`_MAX_AGENTS`
341 before any I/O.
342 * ``--commit`` refs are sanitised by :func:`~muse.core.store.resolve_commit_ref`.
343 * ``--language`` values are passed through
344 :func:`~muse.core.validation.sanitize_display` before text output to
345 prevent ANSI injection.
346 * All file paths in text output pass through
347 :func:`~muse.core.validation.sanitize_display`.
348 * Error messages go to *stdout* as compact JSON when ``--format json``, or
349 to *stderr* with an ``❌`` prefix otherwise.
350
351 Performance
352 -----------
353 * Import-graph build is O(F × S) where F is the number of files in the
354 manifest and S is the average number of symbols per file.
355 * Connected-components DFS is O(F + E) where E is the number of import
356 edges.
357 * Greedy partition is O(C log C + C × N) where C is the number of
358 components and N the number of shards.
359 * Shards are balanced by symbol count using the LPT heuristic
360 (largest-processing-time first), which gives a ≤ 4/3 OPT approximation.
361
362 Args:
363 args: Parsed ``argparse.Namespace`` with attributes ``agents``,
364 ``ref``, ``language``, and ``fmt``.
365
366 Exit codes:
367 0 — success (no files found is also success).
368 1 — bad arguments or commit not found.
369 """
370 as_json: bool = args.fmt == "json"
371 elapsed = start_timer()
372
373 # ── Input validation (before any file I/O) ────────────────────────────────
374
375 agents_raw: int = args.agents
376 if not (_MIN_AGENTS <= agents_raw <= _MAX_AGENTS):
377 msg = f"--agents must be {_MIN_AGENTS}–{_MAX_AGENTS}, got {agents_raw}"
378 _err(msg, as_json, "bad_args")
379 raise SystemExit(ExitCode.USER_ERROR)
380
381 agents: int = agents_raw
382 ref: str | None = args.ref
383 language: str | None = args.language
384
385 root = require_repo()
386 repo_id = read_repo_id(root)
387 branch = read_current_branch(root)
388
389 commit = resolve_commit_ref(root, repo_id, branch, ref)
390 if commit is None:
391 msg = f"commit '{sanitize_display(ref or 'HEAD')}' not found"
392 _err(msg, as_json, "commit_not_found")
393 raise SystemExit(ExitCode.USER_ERROR)
394
395 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
396 sym_map = symbols_for_snapshot(root, manifest, language_filter=language)
397 sym_counts: _CounterMap = {fp: len(tree) for fp, tree in sym_map.items()}
398
399 files = sorted(sym_counts.keys())
400 if not files:
401 if as_json:
402 print(json.dumps({
403 "schema_version": __version__,
404 "commit": short_id(commit.commit_id),
405 "full_commit_id": commit.commit_id,
406 "agents": agents,
407 "shards_created": 0,
408 "total_files": 0,
409 "total_symbols": 0,
410 "cross_shard_edges": 0,
411 "shards": [],
412 "duration_ms": elapsed(),
413 }))
414 else:
415 print(" (no semantic files found in snapshot)")
416 return
417
418 edges = _build_import_edges(root, manifest, language)
419 components = _connected_components(files, edges)
420 n = min(agents, len(components)) # Can't have more shards than components.
421 shard_sets = _greedy_partition(components, sym_counts, n)
422
423 # Compute cross-shard edges.
424 file_to_shard: _ShardMap = {}
425 for i, s in enumerate(shard_sets):
426 for f in s:
427 file_to_shard[f] = i
428 cross_edges = sum(
429 1 for a, b in edges
430 if file_to_shard.get(a, -1) != file_to_shard.get(b, -2)
431 )
432
433 total_symbols = sum(sym_counts.values())
434
435 if as_json:
436 print(json.dumps({
437 "schema_version": __version__,
438 "commit": short_id(commit.commit_id),
439 "full_commit_id": commit.commit_id,
440 "agents": agents,
441 "shards_created": n,
442 "total_files": len(files),
443 "total_symbols": total_symbols,
444 "cross_shard_edges": cross_edges,
445 "shards": [
446 {
447 "shard": i + 1,
448 "files": sorted(s),
449 "symbol_count": sum(sym_counts.get(f, 0) for f in s),
450 "coupling_score": sum(
451 1 for a, b in edges
452 if (a in s) != (b in s)
453 ),
454 }
455 for i, s in enumerate(shard_sets) if s
456 ],
457 "duration_ms": elapsed(),
458 }))
459 return
460
461 # ── Text output ───────────────────────────────────────────────────────────
462 print(f"\nShard plan — {n} agent(s), commit {short_id(commit.commit_id)}")
463 if language:
464 print(f" (language: {sanitize_display(language)})")
465 print("─" * 62)
466
467 for i, s in enumerate(shard_sets):
468 if not s:
469 continue
470 sym_total = sum(sym_counts.get(f, 0) for f in s)
471 coupling = sum(1 for a, b in edges if (a in s) != (b in s))
472 print(f"\n Shard {i + 1} ({sym_total} symbols, {len(s)} files, coupling {coupling}):")
473 for fp in sorted(s):
474 print(f" {sanitize_display(fp)}")
475
476 print(f"\n Cross-shard edges: {cross_edges}")
477 if cross_edges == 0:
478 print(" ✅ Perfect isolation — no cross-shard dependencies")
479 print(f"\n ({elapsed():.3f}s)")
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 146 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 148 days ago