Fourth in a series on how software architecture shapes AI-driven code degradation. The previous post argued that we should score a change, not a snapshot. This post builds that score.
We left the last post with a wish list. Score a change, not the code. Weight it by how much existing tangled code the change disturbs. Make isolated new work nearly free. Do not let fragmentation off the hook. And do not be fooled by shallow file splitting.
That is a lot to ask of one number. Here is how it fits into one. Built one decision at a time.
Attempt one: count what you touch
The simplest measure of disturbance is blast radius. How many existing functions did this change modify? Adding a new file touches nothing that was there before. Reaching into ten existing functions touches ten.
This is a real signal, and we track it. But it is too blunt. It treats every function as equal. Modifying a trivial getter counts the same as modifying a two hundred line god method. That is clearly wrong. Not all disturbance is equal.
Attempt two: weight by complexity
So weight each touched function by its complexity. A change that edits a complex function, and edits a lot of it, scores higher than a small poke at a simple one. Multiply complexity by the number of lines changed.
This is better, but it repeats the exact mistake from the erosion post. It charges for the complexity of the changed function itself. A lone soundex algorithm in its own class is complex. Under this scheme, writing it scores high. Even though it is isolated and disturbs nothing. We would be punishing clean, separate code again.
Attempt three: weight by the context, not the unit
Here is the fix, and it is the heart of the metric. Do not weight a change by the complexity of the function it touches. Weight it by the complexity of everything around that function. The other methods in the same class.
Call that the surrounding weight. It is the sum of the complexity of the other methods in the class. It stands for the context you must hold in your head to change this code safely.
Now the numbers behave the way intuition says they should. Edit a method sitting inside a heavy god class, and the surrounding weight is large. The change is expensive. Add a method to a fresh, empty class, and the surrounding weight is zero. The change is nearly free. The soundex algorithm, alone in its own class, costs almost nothing. It disturbs no surrounding context. That is correct. That is the whole idea.
This is the difference between measuring the code and measuring the arrangement. The same edit costs a little in a lean class and a lot in a god class. The architecture that keeps classes lean pays less for every change it makes.
Closing the loopholes
An honest metric has to survive people trying to game it. Two holes needed plugging.
The first is fragmentation. If a brand new class has a surrounding weight of zero, then an agent could win by shattering everything into tiny, empty, cohesionless classes. That is the Modular Mirage from the last post. So we put a floor of one on the surrounding weight. A new isolated unit is no longer completely free. It costs a small, real amount, proportional to its own complexity. Genuine isolation is cheap. Endless fragmentation is not.
The second hole is spread. A single change that reaches into many files is less cohesive than one that stays in a few. So we multiply the whole change by the number of files it touched. Scattering a rule across the codebase costs more than keeping it in one place. This is a deliberate trade. It buys resistance to gaming at the cost of a little separating power, because concentrating in fewer files is something the mutative arm happens to do. We made that trade on purpose and we say so.
One more guard. A rename can look like deleting an old function and adding a free new one. So within a change, if a new function's body closely matches a function that just disappeared, we score it as a modification, not a free addition. Edits cannot hide behind renames.
The formula
Put it together. Each function a change touches has a cost. The whole change sums those costs, then multiplies by the number of files it touched.
cost(function) = max(surrounding_weight, 1) × complexity × max(1, lines_changed)
change_impact = files_changed × Σ cost(function)summed over every function the change touched, where:
surrounding_weight= sum of the complexity of the other methods in the function's class (the context you must hold to change it safely)complexity= the function's own cyclomatic complexitylines_changed= lines the change added or edited in the function (a new function has 0 change)files_changed= number of production files the whole change touched
A new isolated function pays a small floor. A big edit to a complex method inside a heavy class pays a lot. A change smeared across many files pays the file multiplier on top. And here is the part that makes it a degradation signal. As the god method's class grows, the surrounding weight grows too. So the same small edit costs more every time. The mutative arm digs its own hole deeper with each change. The additive arm never starts digging.
What it does to the two arms
We fit this to both architectures across the whole run, then bootstrap the difference between them. The between arm difference excludes zero by a wide margin. Spring pays far more structural cost per change than OfficeFloor, and the gap grows over time.
The size of the gap is worth sitting with. By the end of a run, a single change to the mutative code base disturbs on the order of fifteen times more weighted structure than the same kind of change to the additive one. Early on the two are close. The distance opens with every rule.
But is the number real?
It is easy to invent a metric that tells a nice story. The harder question is whether it means anything. So we checked it against signals it has no access to. The metric is computed purely from the code diff. It never sees how much the change cost the agent, how long it took, or whether it broke a test.
It predicts all three.
Within each architecture, a change the metric scores as high impact independently costs the agent more money, takes more model time, and forces more re-reading of the code. The correlations are moderate and clear, and they hold inside each arm, so this is not just an artifact of one arm being harder overall.
| The metric versus an independent signal | Correlation, additive arm | Correlation, mutative arm |
|---|---|---|
| Money the change cost the agent | 0.69 | 0.54 |
| Re-reading of existing code | 0.60 | 0.49 |
| Model time spent | 0.68 | 0.53 |
The sharpest test is breakage. Some changes broke a rule the agent was never asked to touch. Those changes scored roughly seven to ten times higher on impact than changes that broke nothing. The metric does not just track effort. It flags the changes most likely to quietly break something.
That is the validation. The score is not a story we like. It is a construct that predicts cost, comprehension, and unintended damage, all measured independently of how it is computed.
Standing on older work
The idea of scoring a change by the risk of what it touches is not new, and we do not claim it. The Delta Maintainability Model scores each commit by the risk of the code it changes. Behavioral code analysis has long ranked hotspots by complexity times change frequency. Others have measured the entropy of how changes scatter across a codebase. All of that is prior art, and all of it is close family.
What is different here is small but pointed. We weight a change by the complexity of the code around it, not the code in it. We make isolated new units nearly free by design. And we use it as the deciding measure in a controlled experiment where the architecture is the only thing that changes. The metric is a tool. The experiment is the point.
There is one question this metric sets up but does not answer. Adding a new rule is one thing. But what happens when an existing rule has to change? That is where the two architectures should differ the most, and it is the subject of the next post.
No comments:
Post a Comment