orchestrata_ultimate.factory.amendment.coupling
Reference › orchestrata_ultimate › factory › amendment › coupling
coupling supplies the coupling signal of the amendment classifier (SPEC-033 row B16, ADR-0021). ADR-0021 names three ways naive edge counting can be laundered; this module closes two of them and leaves the third (cumulative budget refill) out of scope, since it measures only one before-and-after change. boundary_change treats any change to the set of components (a merge, split or move) as loosening by definition. transitive_coupling computes the reachability closure of the edge set with Warshall's algorithm, so a new indirect dependency shows up as closure growth. import_edges builds the cross-component import edges among a list of changed files by parsing imports; _module_to_candidates and _resolve_import place an import on a file, and imports they cannot place are skipped, never guessed. component_of is a documented heuristic: the first two path segments under src/. coupling_verdict turns old and new edges plus the boundary flag into a Direction, and coupling_note gives a one-line summary for the CLI. classify and scripts.factory_amend import it; tests are in tests/factory/test_amendment_coupling.py.
At a glance
| Source | src/orchestrata_ultimate/factory/amendment/coupling.py |
| Lines of code | 217 |
| Classes | 0 |
| Functions | 8 |
| Imports (internal) | orchestrata_ultimate.factory.amendment.models |
| Imported by | orchestrata_ultimate.factory.amendment.classify, scripts.factory_amend |
| Tests that import it | tests/factory/test_amendment_coupling.py |
Class diagram
No classes; see Functions.
Dependency diagram
API
Functions
| Function | Signature | Summary |
|---|---|---|
component_of |
component_of(module_path: str | Path) -> str |
The first two path segments under src/... as the "component" -- e.g. src/orchestrata_ultimate/gateway/handlers/auth.py -> "orchestrata_ultimate/gateway". |
_module_to_candidates |
_module_to_candidates(base: Path, dotted: str) -> list[Path] |
Candidate files a dotted module name (relative to base, a directory) could resolve to: name.py or name/__init__.py. |
_resolve_import |
_resolve_import(repo_root: Path, importing_file: Path, module: str | None, level: int) -> Path | None |
Best-effort resolution of one import statement to an in-repo file. |
import_edges |
import_edges(repo_root: str | Path, changed_files: list[str]) -> set[tuple[str, str]] |
Cross-component import edges among changed_files. |
transitive_coupling |
transitive_coupling(edges: set[tuple[str, str]]) -> set[tuple[str, str]] |
The reachability closure over edges (Warshall's algorithm). |
boundary_change |
boundary_change(old_components: set[str], new_components: set[str]) -> bool |
True iff the component SET changed (a merge, split, or move) -- ADR-0021: boundary changes are loosening BY DEFINITION, regardless of how the edge count moves (attack #1). |
coupling_note |
coupling_note(old_edges: set[tuple[str, str]], new_edges: set[tuple[str, str]], boundary_changed: bool) -> str |
A one-line human-readable summary of the coupling signal, for the CLI report -- not itself part of the classification contract. |
coupling_verdict |
coupling_verdict(old_edges: set[tuple[str, str]], new_edges: set[tuple[str, str]], boundary_changed: bool) -> Direction |
The coupling-based direction signal (ADR-0021). |
Source
"""ADR-0021 coupling accounting: transitive closure + boundary-change detection.
ADR-0021's three laundering attacks on naive direct-edge counting, and why
this module closes them:
1. **Boundary redefinition** -- merging two components makes a
cross-component edge vanish with identical underlying coupling.
:func:`boundary_change` treats ANY change to the component SET (merge,
split, move) as loosening BY DEFINITION, independent of edge count.
2. **Indirection laundering** -- A->B and B->C already exist; A gaining a
dependency on C introduces zero NEW direct edges. :func:`transitive_coupling`
counts over the reachability CLOSURE, so A->C via B is already present in
the closure before the change and any genuinely new indirection shows up
as closure growth.
3. **Refill drift** -- a per-change budget refill masks cumulative growth.
Out of scope here (ADR-0021: "the budget is cumulative against trunk,
with an explicit repayment rule"); this module only measures one
before/after delta, it does not track a cumulative ledger.
``component_of`` is a documented HEURISTIC (first two path segments under
``src/...``), not a registry lookup -- there is no canonical component
registry in this codebase yet. ``import_edges`` is a best-effort AST-based
import resolver: unresolvable imports (external packages, dynamic imports,
imports the resolver's relative-import arithmetic can't place) are silently
skipped, never guessed at.
"""
from __future__ import annotations
import ast
from pathlib import Path
from orchestrata_ultimate.factory.amendment.models import Direction
def component_of(module_path: str | Path) -> str:
"""The first two path segments under ``src/...`` as the "component" --
e.g. ``src/orchestrata_ultimate/gateway/handlers/auth.py`` ->
``"orchestrata_ultimate/gateway"``.
Heuristic, not a registry: a path with no ``src`` segment falls back to
its own first two segments (best-effort for non-``src`` inputs such as
bare dotted module names already split into path form by the caller).
"""
parts = Path(module_path).parts
if "src" in parts:
idx = parts.index("src")
segs = parts[idx + 1 : idx + 3]
else:
segs = parts[:2]
return "/".join(segs)
def _module_to_candidates(base: Path, dotted: str) -> list[Path]:
"""Candidate files a dotted module name (relative to ``base``, a
directory) could resolve to: ``name.py`` or ``name/__init__.py``."""
if not dotted:
return [base / "__init__.py"]
target = base.joinpath(*dotted.split("."))
return [target.with_suffix(".py"), target / "__init__.py"]
def _resolve_import(
repo_root: Path, importing_file: Path, module: str | None, level: int
) -> Path | None:
"""Best-effort resolution of one import statement to an in-repo file.
Absolute imports (``level == 0``) resolve against ``repo_root/src``.
Relative imports (``level > 0``) resolve against the importing file's
own package directory, walking up ``level - 1`` additional parents (the
standard relative-import arithmetic: ``level == 1`` is "this package").
Returns ``None`` when no candidate file exists (an external package, a
dynamic import, or an unresolvable relative import) -- never guessed.
"""
if level == 0:
if not module:
return None
src_root = repo_root / "src"
for candidate in _module_to_candidates(src_root, module):
if candidate.is_file():
return candidate
return None
pkg_dir = importing_file.parent
for _ in range(level - 1):
pkg_dir = pkg_dir.parent
for candidate in _module_to_candidates(pkg_dir, module or ""):
if candidate.is_file():
return candidate
return None
def import_edges(
repo_root: str | Path, changed_files: list[str]
) -> set[tuple[str, str]]:
"""Cross-component import edges among ``changed_files``.
Parses each changed ``.py`` file with :mod:`ast`, resolves every
``import``/``from ... import`` target that lands inside ``repo_root/src``
to a file, and emits an edge ``(component_of(changed_file),
component_of(resolved_target))`` whenever the two components differ.
Non-Python changed files and unresolvable imports contribute nothing.
"""
root = Path(repo_root)
edges: set[tuple[str, str]] = set()
for raw in changed_files:
path = Path(raw)
full_path = path if path.is_absolute() else root / path
if full_path.suffix != ".py" or not full_path.is_file():
continue
try:
tree = ast.parse(full_path.read_text(encoding="utf-8"))
except SyntaxError, UnicodeDecodeError, OSError:
continue
from_component = component_of(full_path)
for node in ast.walk(tree):
resolved: Path | None = None
if isinstance(node, ast.Import):
for alias in node.names:
resolved = _resolve_import(root, full_path, alias.name, 0)
if resolved is not None:
to_component = component_of(resolved)
if to_component != from_component:
edges.add((from_component, to_component))
continue
if isinstance(node, ast.ImportFrom):
resolved = _resolve_import(root, full_path, node.module, node.level)
if resolved is not None:
to_component = component_of(resolved)
if to_component != from_component:
edges.add((from_component, to_component))
return edges
def transitive_coupling(edges: set[tuple[str, str]]) -> set[tuple[str, str]]:
"""The reachability closure over ``edges`` (Warshall's algorithm).
Coupling is counted over the CLOSURE, not the direct edges, so an
indirection through an existing hop (A->B, B->C already present) is
visible as A->C (ADR-0021 attack #2).
"""
nodes = sorted({n for edge in edges for n in edge})
reach: dict[str, dict[str, bool]] = {
i: {j: (i, j) in edges for j in nodes} for i in nodes
}
for k in nodes:
for i in nodes:
if reach[i][k]:
for j in nodes:
if reach[k][j]:
reach[i][j] = True
return {(i, j) for i in nodes for j in nodes if i != j and reach[i][j]}
def boundary_change(old_components: set[str], new_components: set[str]) -> bool:
"""True iff the component SET changed (a merge, split, or move) --
ADR-0021: boundary changes are loosening BY DEFINITION, regardless of
how the edge count moves (attack #1)."""
return old_components != new_components
def coupling_note(
old_edges: set[tuple[str, str]],
new_edges: set[tuple[str, str]],
boundary_changed: bool,
) -> str:
"""A one-line human-readable summary of the coupling signal, for the CLI
report -- not itself part of the classification contract."""
if boundary_changed:
return (
"component boundary changed (merge/split/move) -- loosening by definition"
)
old_closure = transitive_coupling(old_edges)
new_closure = transitive_coupling(new_edges)
added = new_closure - old_closure
removed = old_closure - new_closure
if not added and not removed:
return "coupling closure unchanged"
if added and removed:
return (
f"coupling closure mixed: +{len(added)} edge(s), -{len(removed)} edge(s) "
"-- conflicting signal"
)
if added:
return f"coupling closure grew by {len(added)} edge(s): {sorted(added)}"
return f"coupling closure shrank by {len(removed)} edge(s): {sorted(removed)}"
def coupling_verdict(
old_edges: set[tuple[str, str]],
new_edges: set[tuple[str, str]],
boundary_changed: bool,
) -> Direction:
"""The coupling-based direction signal (ADR-0021).
- A boundary change is loosening, full stop.
- Otherwise, compare the transitive closures: a PURE shrink or a PURE
growth is treated as compatible with a syntactic tightening verdict
(closure growth is real but its budgeting is a separate, out-of-scope
mechanism -- ADR-0021's cumulative edge budget -- so this classifier
does not itself turn coupling growth into an auto-loosening call).
A MIXED change (some edges added, some removed -- the closure grew in
one place and shrank in another) is undetermined: the two sub-signals
conflict and neither dominates.
Coupling is an INPUT to the human decision (or to
:func:`~orchestrata_ultimate.factory.amendment.classify.classify_amendment`'s
fail-closed downgrade), never a standalone gate in its own right.
"""
if boundary_changed:
return Direction.LOOSENING
old_closure = transitive_coupling(old_edges)
new_closure = transitive_coupling(new_edges)
added = new_closure - old_closure
removed = old_closure - new_closure
if added and removed:
return Direction.UNDETERMINED
return Direction.TIGHTENING