Skip to content

drift(basis): the import-triad term penalizes decomposition quadratically, and layered_dag's ideal (021U = 0) is unreachable for any package whose modules share a dependency #554

Description

@zaebee

Two independent measurements on Ownima owner-api, from two sessions that did not coordinate on this, say the same thing: splitting a module raises a domain's drift score, and the term that does it has an ideal no real package can reach. Distinct from #552 (which is about what gets scored); this is about the basis and the ideal vectors.

The observation

A colleague refactored app/domains/reservation/routes.py — four helper functions moved verbatim into two new modules in the same package, one function deleted, no behaviour changed, no raise altered. Drift on app.domains.reservation went 0.494 → 0.506. They report t_calls unchanged and the whole delta in t_imports.

My own run on a slightly earlier main put the same domain at 0.468 with its single largest violation being:

T_imports[021U] = 0.489 vs ideal 0.000 (+0.24)

layered_dag's declared t_imports ideal is [0.5, 0.0, 0.5, 0, …] — 021D and 021C only, zero 021U.

The mechanism, measured rather than reasoned

021U is the convergent triad: two sources pointing at one target. So every pair of modules in a package that import the same thing contributes one. In app.domains.reservation, production code only (is_test = 0):

WITH res AS (SELECT id FROM nodes WHERE id LIKE 'app.domains.reservation%' AND is_test=0),
     imp AS (SELECT DISTINCT e.source, e.target FROM edges e JOIN res s ON e.source=s.id
             WHERE e.type LIKE '%IMPORT%')
SELECT COUNT(*), SUM(n), SUM(n*(n-1)/2)
  FROM (SELECT target, COUNT(DISTINCT source) n FROM imp GROUP BY target HAVING n>=2);
-- shared_targets 108 | converging_edges 358 | 021U triads 687

The top shared targets:

target importers
app.gen.entities 14
app.gen.entities.Reservation 11
app.core.constants 11
datetime 9
app.storage.base 9
app.models 9

687 convergent triads out of ~20 production modules, and the single biggest contributor is the domain importing its own generated entity types. datetime is in the list.

Why a split makes it strictly worse: convergence on one target grows as n(n-1)/2 in the number of importing modules. Take one module that imports app.gen.entities, split it into three that each still need it, and that target's contribution goes from C(14,2) = 91 to C(16,2) = 120. The move adds no edge the code did not already have — it re-partitions the same imports across more files, and the census counts pairs of files.

Why this is a defect and not a tuning question

  1. The ideal is unreachable. 021U = 0 asks that no two modules in a package share any dependency — not the domain's entity types, not its constants, not datetime. No package satisfies that above two modules.
  2. It inverts the signal. Decomposition is the refactor an architectural metric should reward; here it is the one thing guaranteed to make the number worse. A domain can only improve t_imports by merging modules.
  3. It is already costing a decision. Rather than loosen or tighten a tolerance to accommodate an artifact, my colleague closed their refactor's drift criterion as unreachable by this route. That is the right call and it means the metric gave them nothing — which is the outcome worth avoiding.

Candidate directions, least invasive first

  1. Exclude stdlib, external and generated targets from the import census. datetime convergence says nothing about design, and app.gen.* is protoc output. nodes.is_generated and the namespace column are already there (see feat(drift): cgis_drift has no --exclude/--scope, so the hygiene gate measures test volume and logging density #552, where is_test turned out to be populated and unread).
  2. Normalize the import census per module, not per triad pair, so re-partitioning the same dependency set across more files is neutral by construction. This is the direct fix for the quadratic.
  3. Reconsider whether layered_dag should specify 021U = 0 at all. This is the substantive question. A layered DAG in practice converges: many modules in a layer depend on the layer below. If the template means "no convergence", it is describing a tree, not a DAG — and a tree is not what any of these domains is or should be.
  4. Weight t_calls above t_imports. Calls carry behaviour; imports are substantially a function of file granularity, which is a style choice. Note the colleague's refactor left t_calls untouched — the term that tracked the behaviour correctly reported that nothing happened.

(1) and (2) compose and are mechanical. (3) wants a decision from whoever owns the alphabet. (4) is a knob that would mask (1)–(3) rather than fix them.

Reproduction

owner-api, ingest root ownima-backend, ontology ownima-backend/docs/ontology/patterns.yaml, domain app.domains.reservation, expected pattern layered_dag. Compare drift_score and actual.t_imports across a commit that only moves functions between modules of one package.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingshould-haveImportant for scale and quality

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions