compos-cli 0.0.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.
compos/core/graph.py ADDED
@@ -0,0 +1,648 @@
1
+ """Relationship graph traversal (blast radius, cycle detection)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+
10
+ from compos.schema import (
11
+ Component,
12
+ ComposMap,
13
+ Constraint,
14
+ ConstraintStatus,
15
+ Decision,
16
+ DecisionStatus,
17
+ ObjectStatus,
18
+ Relationship,
19
+ RelationshipPattern,
20
+ RelationshipType,
21
+ Risk,
22
+ RiskStatus,
23
+ )
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Enums
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ class Direction(StrEnum):
31
+ """Direction for graph traversal."""
32
+
33
+ OUTGOING = "outgoing"
34
+ INCOMING = "incoming"
35
+ BOTH = "both"
36
+
37
+
38
+ class ChangeType(StrEnum):
39
+ """Type of proposed change — determines blast radius direction."""
40
+
41
+ MODIFY = "modify"
42
+ REMOVE = "remove"
43
+ REPLACE = "replace"
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Result dataclasses (frozen, slots)
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class ComponentContext:
53
+ """Context around one or more focal components."""
54
+
55
+ focal_components: tuple[Component, ...]
56
+ neighbor_components: tuple[Component, ...]
57
+ connecting_relationships: tuple[Relationship, ...]
58
+ applicable_constraints: tuple[Constraint, ...]
59
+ applicable_risks: tuple[Risk, ...]
60
+ applicable_decisions: tuple[Decision, ...]
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class AffectedComponent:
65
+ """A component affected by a change, with distance and path info."""
66
+
67
+ component: Component
68
+ hop_distance: int
69
+ path: tuple[str, ...]
70
+ relationship_types: frozenset[RelationshipType]
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class BlastRadiusResult:
75
+ """Result of a blast radius analysis."""
76
+
77
+ source_component: Component
78
+ change_type: ChangeType
79
+ affected: tuple[AffectedComponent, ...]
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class CycleInfo:
84
+ """A detected dependency cycle."""
85
+
86
+ component_ids: tuple[str, ...]
87
+ relationship_ids: tuple[str, ...]
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class SPOFInfo:
92
+ """A single point of failure candidate."""
93
+
94
+ component: Component
95
+ dependent_count: int
96
+ synchronous_dependents: tuple[Component, ...]
97
+ has_error_behavior: bool
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class ConstraintConflict:
102
+ """A constraint that may be violated by a proposed change."""
103
+
104
+ constraint: Constraint
105
+ reason: str
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class ChangeEvaluation:
110
+ """Result of evaluating a proposed change."""
111
+
112
+ cycles: tuple[CycleInfo, ...]
113
+ spofs: tuple[SPOFInfo, ...]
114
+ constraint_conflicts: tuple[ConstraintConflict, ...]
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # RelationshipGraph — pre-indexed adjacency structure
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ class RelationshipGraph:
123
+ """Pre-indexed relationship graph built from a ComposMap.
124
+
125
+ Builds adjacency indexes in __init__ by iterating relationships once.
126
+ All queries are then O(1) lookups or BFS/DFS over the pre-built indexes.
127
+ """
128
+
129
+ def __init__(
130
+ self, compos_map: ComposMap, *, include_removed: bool = False
131
+ ) -> None:
132
+ self._map = compos_map
133
+
134
+ # Index components by id, filtering removed unless opted in
135
+ self._components: dict[str, Component] = {}
136
+ removed_component_ids: set[str] = set()
137
+ for comp in compos_map.components:
138
+ if not include_removed and comp.status == ObjectStatus.REMOVED:
139
+ removed_component_ids.add(comp.id)
140
+ continue
141
+ self._components[comp.id] = comp
142
+
143
+ # Index relationships, building adjacency lists in a single pass.
144
+ # Skip removed relationships and relationships referencing removed components.
145
+ self._relationships: dict[str, Relationship] = {}
146
+ self._outgoing: dict[str, list[Relationship]] = defaultdict(list)
147
+ self._incoming: dict[str, list[Relationship]] = defaultdict(list)
148
+ self._out_neighbors: dict[str, set[str]] = defaultdict(set)
149
+ self._in_neighbors: dict[str, set[str]] = defaultdict(set)
150
+
151
+ for rel in compos_map.relationships:
152
+ if not include_removed and rel.status == ObjectStatus.REMOVED:
153
+ continue
154
+ if not include_removed and (
155
+ rel.source in removed_component_ids
156
+ or rel.target in removed_component_ids
157
+ ):
158
+ continue
159
+ self._relationships[rel.id] = rel
160
+ self._outgoing[rel.source].append(rel)
161
+ self._incoming[rel.target].append(rel)
162
+ self._out_neighbors[rel.source].add(rel.target)
163
+ self._in_neighbors[rel.target].add(rel.source)
164
+
165
+ @property
166
+ def component_ids(self) -> frozenset[str]:
167
+ return frozenset(self._components)
168
+
169
+ def neighbors(
170
+ self, component_id: str, *, direction: Direction = Direction.BOTH
171
+ ) -> frozenset[str]:
172
+ """Return IDs of neighboring components. Safe for unknown IDs (empty result)."""
173
+ out = self._out_neighbors.get(component_id, set())
174
+ inc = self._in_neighbors.get(component_id, set())
175
+ if direction == Direction.OUTGOING:
176
+ return frozenset(out)
177
+ if direction == Direction.INCOMING:
178
+ return frozenset(inc)
179
+ return frozenset(out | inc)
180
+
181
+ def relationships_of(
182
+ self, component_id: str, *, direction: Direction = Direction.BOTH
183
+ ) -> tuple[Relationship, ...]:
184
+ """Return Relationship objects connected to a component."""
185
+ out = self._outgoing.get(component_id, [])
186
+ inc = self._incoming.get(component_id, [])
187
+ if direction == Direction.OUTGOING:
188
+ return tuple(out)
189
+ if direction == Direction.INCOMING:
190
+ return tuple(inc)
191
+ # Both: combine, dedup by id (a self-loop would appear in both lists)
192
+ seen: set[str] = set()
193
+ result: list[Relationship] = []
194
+ for rel in (*out, *inc):
195
+ if rel.id not in seen:
196
+ seen.add(rel.id)
197
+ result.append(rel)
198
+ return tuple(result)
199
+
200
+ def relationships_between(
201
+ self, source_id: str, target_id: str
202
+ ) -> tuple[Relationship, ...]:
203
+ """Return all relationships from source_id to target_id (directed)."""
204
+ return tuple(
205
+ rel
206
+ for rel in self._outgoing.get(source_id, [])
207
+ if rel.target == target_id
208
+ )
209
+
210
+ def reachable(
211
+ self,
212
+ component_id: str,
213
+ *,
214
+ direction: Direction = Direction.OUTGOING,
215
+ max_hops: int | None = None,
216
+ ) -> dict[str, int]:
217
+ """BFS from component_id, returning {reachable_id: hop_distance}.
218
+
219
+ The source component itself is excluded from results.
220
+ Direction determines which adjacency index to follow.
221
+ max_hops limits search depth (None = unlimited).
222
+ """
223
+ distances: dict[str, int] = {}
224
+ queue: deque[tuple[str, int]] = deque([(component_id, 0)])
225
+ visited: set[str] = {component_id}
226
+
227
+ while queue:
228
+ current, depth = queue.popleft()
229
+ if max_hops is not None and depth >= max_hops:
230
+ continue
231
+ for neighbor in self.neighbors(current, direction=direction):
232
+ if neighbor not in visited:
233
+ visited.add(neighbor)
234
+ distances[neighbor] = depth + 1
235
+ queue.append((neighbor, depth + 1))
236
+
237
+ return distances
238
+
239
+ def find_cycles(self) -> tuple[CycleInfo, ...]:
240
+ """Detect all elementary cycles using iterative DFS with coloring.
241
+
242
+ WHITE = unvisited, GRAY = on current path, BLACK = fully explored.
243
+ A back-edge to a GRAY node reveals a cycle. The cycle path is
244
+ extracted from the DFS stack.
245
+ """
246
+ WHITE, GRAY, BLACK = 0, 1, 2
247
+ color: dict[str, int] = {cid: WHITE for cid in self._components}
248
+ cycles: list[CycleInfo] = []
249
+
250
+ for start in self._components:
251
+ if color[start] != WHITE:
252
+ continue
253
+
254
+ # Iterative DFS: stack holds (node, iterator_over_outgoing_rels)
255
+ # path tracks the current DFS path for cycle extraction
256
+ path: list[str] = []
257
+ path_set: set[str] = set()
258
+ # rel_on_path[i] = relationship used to reach path[i] from path[i-1]
259
+ rel_on_path: list[str] = []
260
+ stack: list[tuple[str, int]] = [(start, 0)]
261
+ color[start] = GRAY
262
+ path.append(start)
263
+ path_set.add(start)
264
+
265
+ while stack:
266
+ node, idx = stack[-1]
267
+ out_rels = self._outgoing.get(node, [])
268
+
269
+ if idx < len(out_rels):
270
+ # Advance iterator
271
+ stack[-1] = (node, idx + 1)
272
+ rel = out_rels[idx]
273
+ neighbor = rel.target
274
+
275
+ if color.get(neighbor) == GRAY and neighbor in path_set:
276
+ # Back-edge → cycle found. Extract from path.
277
+ cycle_start = path.index(neighbor)
278
+ cycle_nodes = path[cycle_start:] + [neighbor]
279
+ cycle_rels = rel_on_path[cycle_start:] + [rel.id]
280
+ cycles.append(
281
+ CycleInfo(
282
+ component_ids=tuple(cycle_nodes),
283
+ relationship_ids=tuple(cycle_rels),
284
+ )
285
+ )
286
+ elif color.get(neighbor) == WHITE:
287
+ color[neighbor] = GRAY
288
+ path.append(neighbor)
289
+ path_set.add(neighbor)
290
+ rel_on_path.append(rel.id)
291
+ stack.append((neighbor, 0))
292
+ else:
293
+ # All neighbors explored — backtrack
294
+ color[node] = BLACK
295
+ stack.pop()
296
+ path.pop()
297
+ path_set.discard(node)
298
+ if rel_on_path:
299
+ rel_on_path.pop()
300
+
301
+ return tuple(cycles)
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # High-level functions — compose graph primitives with schema lookups
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def get_component_context(
310
+ compos_map: ComposMap,
311
+ focal_component_ids: Sequence[str],
312
+ *,
313
+ include_removed: bool = False,
314
+ ) -> ComponentContext:
315
+ """Collect one-hop neighborhood + applicable constraints/risks/decisions.
316
+
317
+ Raises KeyError if any focal_component_id is not in the graph.
318
+ """
319
+ graph = RelationshipGraph(compos_map, include_removed=include_removed)
320
+
321
+ # Validate focal IDs exist
322
+ for cid in focal_component_ids:
323
+ if cid not in graph._components:
324
+ msg = f"Component not found: {cid!r}"
325
+ raise KeyError(msg)
326
+
327
+ focal_set = set(focal_component_ids)
328
+ focal_components = tuple(graph._components[cid] for cid in focal_component_ids)
329
+
330
+ # Collect one-hop neighbors (excluding focal components themselves)
331
+ neighbor_ids: set[str] = set()
332
+ for cid in focal_component_ids:
333
+ neighbor_ids |= graph.neighbors(cid, direction=Direction.BOTH)
334
+ neighbor_ids -= focal_set
335
+ neighbor_components = tuple(
336
+ graph._components[nid] for nid in neighbor_ids if nid in graph._components
337
+ )
338
+
339
+ # Collect relationships connecting focal components to their neighbors
340
+ all_relevant_ids = focal_set | neighbor_ids
341
+ rel_set: set[str] = set()
342
+ rels: list[Relationship] = []
343
+ for cid in focal_component_ids:
344
+ for rel in graph.relationships_of(cid, direction=Direction.BOTH):
345
+ if rel.id not in rel_set:
346
+ rel_set.add(rel.id)
347
+ rels.append(rel)
348
+
349
+ # Filter constraints that apply to any focal or neighbor component
350
+ constraint_filter = _active_filter(compos_map, include_removed)
351
+ applicable_constraints = tuple(
352
+ c
353
+ for c in constraint_filter.constraints
354
+ if set(c.applies_to) & all_relevant_ids
355
+ )
356
+
357
+ # Filter risks involving any focal or neighbor component/relationship
358
+ applicable_risks = tuple(
359
+ r
360
+ for r in constraint_filter.risks
361
+ if set(r.components_involved) & all_relevant_ids
362
+ or set(r.relationships_involved) & rel_set
363
+ )
364
+
365
+ # Filter decisions affecting any focal or neighbor component/relationship
366
+ applicable_decisions = tuple(
367
+ d
368
+ for d in constraint_filter.decisions
369
+ if set(d.components_affected) & all_relevant_ids
370
+ or set(d.relationships_affected) & rel_set
371
+ )
372
+
373
+ return ComponentContext(
374
+ focal_components=focal_components,
375
+ neighbor_components=neighbor_components,
376
+ connecting_relationships=tuple(rels),
377
+ applicable_constraints=applicable_constraints,
378
+ applicable_risks=applicable_risks,
379
+ applicable_decisions=applicable_decisions,
380
+ )
381
+
382
+
383
+ def get_blast_radius(
384
+ compos_map: ComposMap,
385
+ component_id: str,
386
+ change_type: ChangeType,
387
+ *,
388
+ include_removed: bool = False,
389
+ max_hops: int | None = None,
390
+ ) -> BlastRadiusResult:
391
+ """Compute which components are affected by a change.
392
+
393
+ Direction is determined by change_type:
394
+ - MODIFY/REPLACE → outgoing (downstream consumers)
395
+ - REMOVE → both directions (upstream and downstream)
396
+
397
+ Raises KeyError if component_id is not in the graph.
398
+ """
399
+ graph = RelationshipGraph(compos_map, include_removed=include_removed)
400
+
401
+ if component_id not in graph._components:
402
+ msg = f"Component not found: {component_id!r}"
403
+ raise KeyError(msg)
404
+
405
+ # Direction depends on change type
406
+ if change_type == ChangeType.REMOVE:
407
+ direction = Direction.BOTH
408
+ else:
409
+ direction = Direction.OUTGOING
410
+
411
+ affected = _bfs_affected(graph, component_id, direction, max_hops)
412
+
413
+ return BlastRadiusResult(
414
+ source_component=graph._components[component_id],
415
+ change_type=change_type,
416
+ affected=tuple(sorted(affected, key=lambda a: a.hop_distance)),
417
+ )
418
+
419
+
420
+ def get_constraints_for_components(
421
+ compos_map: ComposMap,
422
+ component_ids: Sequence[str],
423
+ *,
424
+ include_removed: bool = False,
425
+ ) -> tuple[Constraint, ...]:
426
+ """Return constraints that apply to any of the given component IDs."""
427
+ target_set = set(component_ids)
428
+ filtered = _active_filter(compos_map, include_removed)
429
+ return tuple(
430
+ c for c in filtered.constraints if set(c.applies_to) & target_set
431
+ )
432
+
433
+
434
+ def evaluate_proposed_change(
435
+ compos_map: ComposMap,
436
+ *,
437
+ proposed_relationships: Sequence[Relationship] = (),
438
+ include_removed: bool = False,
439
+ ) -> ChangeEvaluation:
440
+ """Evaluate a hypothetical change by merging proposed relationships into the map.
441
+
442
+ Builds a temporary map with proposed relationships appended, then runs
443
+ cycle detection, SPOF analysis, and constraint conflict checks.
444
+ """
445
+ # Build hypothetical map with proposed relationships added
446
+ if proposed_relationships:
447
+ hypothetical = ComposMap(
448
+ schema_version=compos_map.schema_version,
449
+ map_version=compos_map.map_version,
450
+ generated_at=compos_map.generated_at,
451
+ git_commit=compos_map.git_commit,
452
+ parent_version=compos_map.parent_version,
453
+ produced_by=compos_map.produced_by,
454
+ project=compos_map.project,
455
+ components=compos_map.components,
456
+ relationships=compos_map.relationships + tuple(proposed_relationships),
457
+ constraints=compos_map.constraints,
458
+ risks=compos_map.risks,
459
+ decisions=compos_map.decisions,
460
+ last_merge=compos_map.last_merge,
461
+ )
462
+ else:
463
+ hypothetical = compos_map
464
+
465
+ graph = RelationshipGraph(hypothetical, include_removed=include_removed)
466
+
467
+ # Detect cycles in the hypothetical graph
468
+ cycles = graph.find_cycles()
469
+
470
+ # Detect SPOFs
471
+ spofs = _find_spofs(graph)
472
+
473
+ # Detect constraint conflicts: any constraint applying to a component
474
+ # touched by the proposed relationships
475
+ constraint_conflicts: list[ConstraintConflict] = []
476
+ if proposed_relationships:
477
+ # Collect component IDs touched by proposed relationships
478
+ touched_ids: set[str] = set()
479
+ for rel in proposed_relationships:
480
+ touched_ids.add(rel.source)
481
+ touched_ids.add(rel.target)
482
+
483
+ filtered = _active_filter(hypothetical, include_removed)
484
+ for con in filtered.constraints:
485
+ if set(con.applies_to) & touched_ids:
486
+ constraint_conflicts.append(
487
+ ConstraintConflict(
488
+ constraint=con,
489
+ reason=(
490
+ f"Proposed relationship touches constrained "
491
+ f"component(s): {set(con.applies_to) & touched_ids}"
492
+ ),
493
+ )
494
+ )
495
+
496
+ return ChangeEvaluation(
497
+ cycles=cycles,
498
+ spofs=spofs,
499
+ constraint_conflicts=tuple(constraint_conflicts),
500
+ )
501
+
502
+
503
+ # ---------------------------------------------------------------------------
504
+ # Private helpers
505
+ # ---------------------------------------------------------------------------
506
+
507
+
508
+ @dataclass(frozen=True, slots=True)
509
+ class _FilteredObjects:
510
+ """Typed container for soft-deletion-filtered ancillary objects."""
511
+
512
+ constraints: tuple[Constraint, ...]
513
+ risks: tuple[Risk, ...]
514
+ decisions: tuple[Decision, ...]
515
+
516
+
517
+ def _active_filter(
518
+ compos_map: ComposMap, include_removed: bool
519
+ ) -> _FilteredObjects:
520
+ """Filter constraints/risks/decisions based on soft-deletion status."""
521
+ if include_removed:
522
+ return _FilteredObjects(
523
+ constraints=compos_map.constraints,
524
+ risks=compos_map.risks,
525
+ decisions=compos_map.decisions,
526
+ )
527
+ return _FilteredObjects(
528
+ constraints=tuple(
529
+ c for c in compos_map.constraints
530
+ if c.status != ConstraintStatus.REMOVED
531
+ ),
532
+ # dismissed risks are still visible — only exclude administratively removed
533
+ risks=tuple(
534
+ r for r in compos_map.risks
535
+ if r.status != RiskStatus.REMOVED
536
+ ),
537
+ decisions=tuple(
538
+ d for d in compos_map.decisions
539
+ if d.status not in {DecisionStatus.REVERSED, DecisionStatus.REMOVED}
540
+ ),
541
+ )
542
+
543
+
544
+ def _next_node(rel: Relationship, current: str, direction: Direction) -> str:
545
+ """Return the neighbor reached by traversing *rel* from *current*."""
546
+ if direction == Direction.OUTGOING:
547
+ return rel.target
548
+ if direction == Direction.INCOMING:
549
+ return rel.source
550
+ return rel.target if rel.source == current else rel.source
551
+
552
+
553
+ def _bfs_affected(
554
+ graph: RelationshipGraph,
555
+ source_id: str,
556
+ direction: Direction,
557
+ max_hops: int | None,
558
+ ) -> list[AffectedComponent]:
559
+ """Single-pass BFS that collects distance, path, and all relationship types.
560
+
561
+ For each hop in the path, ALL relationships between the pair are inspected
562
+ so that parallel edges (e.g. CALL + DATA_FLOW between the same components)
563
+ contribute their types to the result.
564
+ """
565
+ # parent: child_id → (parent_id, representative_rel_id)
566
+ parent: dict[str, tuple[str, str]] = {}
567
+ distances: dict[str, int] = {}
568
+ queue: deque[tuple[str, int]] = deque([(source_id, 0)])
569
+ visited: set[str] = {source_id}
570
+
571
+ while queue:
572
+ current, depth = queue.popleft()
573
+ if max_hops is not None and depth >= max_hops:
574
+ continue
575
+ for rel in graph.relationships_of(current, direction=direction):
576
+ next_id = _next_node(rel, current, direction)
577
+ if next_id not in visited:
578
+ visited.add(next_id)
579
+ distances[next_id] = depth + 1
580
+ parent[next_id] = (current, rel.id)
581
+ queue.append((next_id, depth + 1))
582
+
583
+ # Reconstruct paths and collect ALL relationship types per hop
584
+ result: list[AffectedComponent] = []
585
+ for comp_id, dist in distances.items():
586
+ path_ids: list[str] = []
587
+ rel_types: set[RelationshipType] = set()
588
+ node = comp_id
589
+ while node in parent:
590
+ prev, rep_rel_id = parent[node]
591
+ path_ids.append(rep_rel_id)
592
+ # Collect types from ALL relationships between prev and node
593
+ for rel in graph.relationships_between(prev, node):
594
+ rel_types.add(rel.type)
595
+ # For BOTH/INCOMING, also check the reverse direction
596
+ if direction != Direction.OUTGOING:
597
+ for rel in graph.relationships_between(node, prev):
598
+ rel_types.add(rel.type)
599
+ node = prev
600
+ path_ids.reverse()
601
+ result.append(
602
+ AffectedComponent(
603
+ component=graph._components[comp_id],
604
+ hop_distance=dist,
605
+ path=tuple(path_ids),
606
+ relationship_types=frozenset(rel_types),
607
+ )
608
+ )
609
+
610
+ return result
611
+
612
+
613
+ def _find_spofs(graph: RelationshipGraph) -> tuple[SPOFInfo, ...]:
614
+ """Identify single points of failure in the graph.
615
+
616
+ A SPOF candidate is a component with 2+ incoming synchronous relationships.
617
+ The has_error_behavior flag indicates whether ALL sync dependents define
618
+ error_behavior — if they do, the SPOF is lower severity but still reported.
619
+ """
620
+ spofs: list[SPOFInfo] = []
621
+
622
+ for comp_id, comp in graph._components.items():
623
+ incoming = graph.relationships_of(comp_id, direction=Direction.INCOMING)
624
+ # Filter to synchronous relationships only
625
+ sync_rels = [
626
+ r for r in incoming if r.pattern == RelationshipPattern.SYNCHRONOUS
627
+ ]
628
+ if len(sync_rels) < 2:
629
+ continue
630
+
631
+ sync_dependents = tuple(
632
+ graph._components[r.source]
633
+ for r in sync_rels
634
+ if r.source in graph._components
635
+ )
636
+ # has_error_behavior is True only if ALL sync rels define it
637
+ has_error_behavior = all(r.error_behavior is not None for r in sync_rels)
638
+
639
+ spofs.append(
640
+ SPOFInfo(
641
+ component=comp,
642
+ dependent_count=len(sync_rels),
643
+ synchronous_dependents=sync_dependents,
644
+ has_error_behavior=has_error_behavior,
645
+ )
646
+ )
647
+
648
+ return tuple(spofs)