Saturday, 24 October 2026

Sinews: catching a god class while it is still small

The god class experiment found that complexity concentration finds god classes, but cannot tell a god class from a complex algorithm. This post is about the idea I think tells them apart, the sinew, and the experiment that has now started to test it. There are no results yet, only the idea, why it matters, and how it will be judged.

The god class experiment is finished and published on Zenodo. Its question was can we find god classes/methods by complexity concentration (from the understand complexity is conserved Conserved Amount, Negotiable Placement). The answer was that it nearly can but flags too much. Some were god classes/method. The others were classes that correctly compute complex algorithms.

So concentration is the right signal, but it is only half of one. It says where the complexity is. It does not say whether that place is healthy.

Two kinds of concentration

Think of a complex algorithm and a god class side by side. Both score high on cognitive complexity. Both stand out in their repository. The difference is in how they sit in the system.

The algorithm receives its inputs, works on them, and hands back a result. Data flows in and stops there. It is a leaf.

The god class is the opposite. It receives a request, and then it reaches out: it loads this, checks that, builds the other, passes the owner and the form and the clock and the result to half a dozen collaborators, and decides what happens next. Data flows in and fans out again, carried on the calls. It is a junction.

That is the distinction I want to measure, and the name I have for what makes the difference is a sinew.

What a sinew is

A sinew is data coupled to the control flow: data that reaches the code that needs it only by being passed down the call chain, method to method. The more data rides on the control flow, the stronger the sinew.

Here is the shape, in a deliberately ordinary Spring style handler:

@PostMapping("/owners/{ownerId}/pets")
public String addPet(@PathVariable int ownerId, PetForm form, BindingResult result, Model model) {
    Owner owner = owners.findById(ownerId);
    validate(owner, form, result);
    if (result.hasErrors()) {
        return populate(owner, form, model);
    }
    Pet pet = build(owner, form, clock.now());
    owners.save(owner, pet);
    notify(owner, pet, model);
    return "redirect:/owners/" + ownerId;
}

Nothing here is bad code. But look at what travels. owner is passed to four calls. form to three. model and result are carried along so that something further down can write into them. Every method on those paths has the data in its signature, whether it uses all of it or only passes it on.

The name comes from the body. A sinew is what ties the muscle to the bone: it is what makes things move together. Data threaded through a call chain does exactly that to code. The methods on the path cannot move without each other.

Four harms

Strong sinews make code hard to change, in four ways:

  1. Ripple. Add a field, a parameter, a new piece of context, and every method that carries it changes, including the ones that only pass it on.
  2. Rigidity. A step cannot be inserted, removed or reordered without re-plumbing the signatures either side of it.
  3. Comprehension. To know where a value came from or where it goes, you follow the whole path.
  4. Attraction. Data in scope invites logic.

The fourth is the one that matters here. A method that already holds most of the data a change needs is where the next change is easiest to put. It does not have to be a decision. It is the cheaper path. So the next rule goes there, and the one after that, and the method that held the data now holds the logic too. Complexity collects where the data is.

Gravity

This is why I think of it as gravity. A junction with strong sinews has mass: it holds the data, so it attracts the logic. The logic it attracts brings more data with it, more parameters, more collaborators, more things passed on. That is more mass, and more attraction. The pull grows with what it has already pulled in.

And the pull works against the way out. The cost of refactoring a junction is the cost of cutting its sinews: every path the data rides has to be re-plumbed. So the bigger the junction gets, the more expensive it is to break up, and the more likely the next change is to land in it rather than around it. Past a certain point it is a black hole. Everyone knows it is a god class, nobody can afford to take it apart, and it keeps growing.

That is the reason to want to see it early. A junction that has just started to attract logic is a few signatures and a few calls. Refactoring it is an afternoon. The same junction a year and forty features later is a project. If a measure can say which concentrations are junctions, then it can say, while it is still cheap, which ones are going to keep growing.

Measuring it: coupling divergence

The measure began as coupling divergence: per method, the coupling going out against the coupling coming in. Coupling here is counted from what rides on the control flow. For a method with p parameters that makes calls into its own project, passing a(c) arguments on each call c:

  • in = 1 + p (the call that reached it, plus the data that came with it)
  • out = the sum over its calls of 1 + a(c) (each call it makes, plus the data it sends)
  • d = out − in

That gives each method a role, by analogy with a pipe system:

drolereading
greater than 0junctioncreates and directs flow: hands on more data than it receives
0pipepasses data through
less than 0catchmenta leaf: receives data and processes it

The addPet above is a junction. A parser that takes a string and returns a tree is a catchment.

And that is the classification. Complexity concentrated in a catchment is good concentration: leaf processing doing the work, which is where the complexity of an algorithm belongs. Complexity concentrated in a pipe or a junction is bad concentration: a unit that directs the data and also holds the logic. The gravity well.

There is a picture behind this from the earlier experiments. If complexity is conserved, and an architecture only moves it rather than removes it, then each method takes up area in proportion to its complexity, and the question is where the area sits. In a healthy system most of it sits in the catchments, and the junctions stay thin. A system out of balance puts its area in junctions.

None of the ingredients are new. Tramp data and stamp coupling are in Yourdon and Constantine's Structured Design from 1979, and a sinew is tramp data seen as a path. Henry and Kafura's information flow multiplied complexity by fan-in and fan-out in 1981. Lanza and Marinescu's God Class already combines complexity with access to foreign data. What I have not found is the combination: a signed balance of what a unit sends against what it receives, used to set complex leaves aside, on a scale that each codebase sets for itself.

The experiment

The concentration divergence study asks one question:

In Java, does weighting a unit's complexity by its coupling divergence recover the units people call god classes and methods better than complexity alone, by setting complex leaves aside?

It is built the way the god class study was, and on top of it. The complexity measures, the outlier rule and the statistics are the god class study's, installed from its released commit, so every baseline number is the number that study would produce on the same class. What is added is the coupling: a JavaParser measurer with its symbol solver, which resolves each call to see whether it lands in the project's own code.

The plan was written first and tagged in git (plan-v1) before any measurement code existed. Every change since is its own dated commit, stating what had been seen when it was made.

The things I most want to get right:

  • People decide, not tools. The primary oracle is the MLCQ data set (Madeyski and Lewowski): professional developers reviewing real Java code and saying, without being given a definition, whether a class is a Blob or a method is a Long Method. PMD's GodClass is in the study too, but it already contains a coupling measure, so agreement with it would be partly circular. Only a person can say "this is a god class, that is just a hard algorithm".
  • The base signal is chosen without the factor. On the explore half of MLCQ, the complexity signal is chosen by how well it does on its own. The divergence factor is never consulted, so it cannot pick a signal it happens to win on. The verdict is read once, on the confirm half.
  • The rival explanations get their own arms. A class's divergence is a sum over its methods, so a bigger class tends to have a bigger divergence. If weighting by divergence helps, it could just be weighting by size. So the same weighting by the class's number of methods, which carries no coupling at all, is measured beside it. And the factor does two things at once, it exempts catchments and it amplifies junctions, so each is tested on its own. If the gain all comes from amplifying junctions, the claim that setting leaves aside is what helps is not supported, even if the headline is positive.
  • More than one way to count. Three definitions of coupling run through everything: the one above, where a call is itself coupling; one where only the data counts; and one after Alan Kay, where the first item of a call is the message and coupling starts at the second.
  • More than one population. A fresh random sample of Java repositories on a new seed (seeds 1 and 2 are used and seen), scored against the tool oracles; and Fontana's God Class, 420 classes judged by hand by a different team on the Qualitas Corpus, as a replication.

And there is a competing expectation I have to write down, because I saw it first. On the AI evolved petclinic code from the earlier experiments, it was the outgoing data on its own that carried the signal, and the balance against the incoming added little. If that holds here, what matters is how much a unit sends, and the sinew's balance is the wrong half of the idea. The plan reads that as its own outcome, not as a failure to be explained away.

As of today, the plan is registered, the measurer is built and validated on material already seen, the MLCQ repositories are being measured at the commits their reviewers labelled, and the fresh sample has been drawn. No result against any oracle exists yet.

What this experiment does not test

It looks at one checkout at a time. It asks whether sinews tell bad concentration from good, at the commit the reviewers labelled. It does not ask whether a junction found today grows into a god class next year.

That is deliberate. Before asking whether something shows early, there has to be a definition of the thing that holds up. If coupling divergence does tell god units from complex leaves, the next study takes it into git history and asks the question this post is really about: does a junction with strong sinews attract the next changes, and does it show while it is still small enough to refactor? That one will be registered on its own, judged by what actually happens to the code, not by agreement with another detector.

Where OfficeFloor comes in

I created OfficeFloor, and the sinew idea came out of building it, so I should say plainly where the interest is and how far the claim goes.

Most web frameworks are what I have been calling mutative. The framework gives you one hook per request: a controller method, a handler, a route function. Everything after that is yours to design. The control flow, the logic, which data goes where, all of it starts in that one method. That is a lot of freedom, and freedom is fine when someone is exercising judgement about it.

AI does not seem to. In the earlier experiments, and in others' work, AI tends to concentrate. Zhang, Wen and Tempero found that LLM generated object oriented designs put more of the burden into fewer classes, with the largest class holding a bigger share of the code, while smell detectors registered nothing unusual. He and colleagues found SonarQube's cognitive complexity rose and stayed up in projects after they adopted Cursor. A single handler hook with the data already in scope is exactly the place attraction works on. It is the junction, handed to you on the first line.

OfficeFloor separates the two graphs. The control flow between procedures is configured, and the data is injected. A procedure declares what it needs and what it produces, and does not receive it from whoever called it. Sketched, the first steps of the same handler:

public class AddPet {
    public static void loadOwner(@HttpPathParameter("ownerId") int ownerId,
            OwnerRepository owners, Out<Owner> owner) {
        owner.set(owners.findById(ownerId));
    }

    @Next("invalid")
    public static void validate(@Val Owner owner, PetForm form) { ... }

    public static void build(@Val Owner owner, PetForm form, Clock clock, Out<Pet> pet) { ... }
}

No procedure passes owner to the next. It is a variable that each one that needs it asks for. So in the flow layer, between procedures, no data rides on the control flow, and a sinew cannot form there by construction. Inside a procedure it is ordinary Java, and sinews can form exactly as they do anywhere else, but they cannot reach past the procedure.

So here is the consideration, loosely, and not yet proven by anything. If sinews are where complexity concentrates into god classes, and if AI concentrates code in architectures that hand it one hook and a scope full of data, then an architecture that constrains sinews might be one that contains AI's tendency to concentrate. Not by stopping it from writing complex code, but by not giving the complexity a junction to collect in.

It also has to survive what I have already seen. On the petclinic runs, complexity concentrated about as much in OfficeFloor as in Spring: the Gini coefficient of method complexity rose from 0.23 to 0.34 in Spring and from 0.22 to 0.35 in OfficeFloor. What differed was where it sat. OfficeFloor started with 64% of its complexity in catchments, against Spring's 42%. So the claim, if it holds, is not that OfficeFloor code concentrates less. It is that its concentration lands in leaves rather than junctions, the good kind rather than the kind with gravity. That was one small application evolved by agents, the sixth way of measuring the same run, and it shaped the hypothesis rather than testing it.

That is three ifs, and the experiment that has started tests only the first. It samples repositories at random and does not compare frameworks. The OfficeFloor part is a direction, not a finding, and it has a trap I intend to stay out of. OfficeFloor code will behave differently under these measures, by design. If I only explain that difference after I see it, "OfficeFloor works differently" can explain anything, and the theory stops being testable. So when OfficeFloor code is measured, what it should look like will be written down first.

The short version

Complexity concentration finds god classes but cannot tell them from complex algorithms. The sinew is my candidate for the difference: data threaded down the control flow, which holds code together so that it cannot move apart, and which attracts the next piece of logic to wherever the data already is. A unit that sends out more data than it receives is a junction, and complexity in a junction is the kind that grows, pulling in more complexity and more coupling until it is too heavy to refactor. Complexity in a leaf is just an algorithm. A pre-registered experiment has started to test whether that distinction recovers the classes and methods people call god units better than complexity alone, judged by human reviewers, with size and fan-out given every chance to explain it away first. If it does, the next step is to see whether a junction shows early enough to cut before its gravity wins, and after that, whether an architecture that keeps data off the control flow keeps AI from building the junctions at all.

The plan, its amendments and the harness are public in the repository, with the commits that show when each was made.

Monday, 19 October 2026

Finding god classes without a magic number

A pre-registered test of whether a codebase can be its own yardstick. The headline is mixed, the most useful part is where it fails, and one piece is still to run.

Every god class detector I know of comes with a constant. WMC of 47 or more. Cyclomatic complexity of 14. Cognitive complexity of 15. Those numbers are not arbitrary. Lanza and Marinescu froze percentiles of a 45 project corpus, and the benchmark approaches (Alves and colleagues, Oliveira and colleagues) replace expert opinion with a corpus percentile. But each of them still ships a number on a raw scale, and that number was fitted to somebody else's code.

Why the constants worry me now

Those constants were fitted on code that people wrote. The corpora behind them are human development: human sized methods, human habits of decomposition, the complexity a human team tolerates before it refactors. That is no longer the only code being written. More and more of it is generated, and I do not know whether AI written code has the same distributions, and neither does anyone else yet.

That raises two different questions, and they pull in different directions. Will the constants hold? If a threshold is really a percentile of a population, and the population changes, the percentile drifts and the threshold quietly stops meaning what it meant. Should they hold? If a threshold is really a statement about what a human can hold in their head, it should stay fixed for as long as humans have to read and maintain the code, whoever wrote it. A constant cannot tell you which of those it is.

So I wanted a way to detect the smell that does not depend on tuning a constant to a population at all.

There is a second reason, from the previous experiment. There, an architecture did not change how much complexity a problem carried, only where it was placed: the same complexity, in one unit or in twenty. If complexity is conserved and only moved around, then a god class is not a thing in itself. It is where the complexity has concentrated. And concentration is a relative idea. Whether a class is a concentration depends on the rest of its codebase, not on a number from somebody else's.

Therefore, standardise every class against its own repository, by the median and the median absolute deviation, and flag the ones that stand out. The textbook rule for that is the modified z score of Iglewicz and Hoaglin, flagging at 3.5. It is scale free, though not constant free: 3.5 is still a constant, but it lives on a standardised axis that should not need refitting when the population moves.

So the question I set out to test was simple to state. Does a per repository outlier rule, one that looks for the concentration rather than a number, mark the same god classes that a tuned absolute threshold marks? This experiment uses existing open source code, mostly written before AI assistance, on purpose: if the relative rule cannot match the constants on the code the constants were fitted to, there is no point asking whether it does better on code they were not.

How I tried not to fool myself

I wrote the analysis plan first and tagged it in git before drawing a single repository. The plan fixed the primary definition (PMD's GodClass), the decision rule, the margins, the power targets and the sample seed. The seed that drew the pilot was refused, so the confirmatory sample could not be data the plan had been shaped on.

The sample is 445 open source repositories across ten languages, drawn with a seeded sampler from three popularity tiers, plus a Java supplement. The supplement exists because every composite god class definition is Java only, so the class level result rests on the Java repositories, 171 of them. Before any repository was measured, the harness split them into an explore half and a confirm half and froze that split. Anything chosen, which complexity signal to use and where to put the competing absolute cutoff, was chosen on explore. The verdict was read once, on confirm. The analysis file was hashed and the hash committed and pushed before any further work.

The competitor is deliberately strong. The absolute rule gets the single best raw cutoff tuned on the explore half, which came out as a peak cognitive complexity of 22 per file. The relative rule gets no tuning at all, just the textbook 3.5. If anything, that tilts the comparison against the relative rule.

What came out

On the explore half, the strongest non size signal was the largest cognitive complexity of any method in the file. On confirm it ranks PMD's god classes well, with a mean per repository AUC of 0.92 (0.90 to 0.93), comfortably above the floor of 0.70 the plan required.

Then the head to head, pooled over the 84 confirm Java repositories that carry the definition:

relative (z ≥ 3.5)absolute (cognitive ≥ 22)
god classes found (recall)59%44%
flags that were god classes (precision)30%44%
non god classes flagged7.5%3.1%
MCC0.380.41

The plan decided on three paired comparisons, with the relative rule needing to be within a margin of the absolute one on each:

  • Per repository MCC: a difference of minus 0.009 (minus 0.057 to plus 0.040). Non inferior at the 0.10 margin.
  • Pooled MCC: minus 0.028 (minus 0.068 to plus 0.005). Non inferior.
  • False positive rate: plus 0.042 (plus 0.027 to plus 0.056). Inconclusive at the 0.05 margin.

So the overall verdict, by the rule I wrote down in advance, is inconclusive.

I want to be careful about what inconclusive means here, because it is not "no effect". The false positive interval sits entirely above zero. The relative rule clearly flags more non god classes than the tuned cutoff, more than twice as many when pooled. What is unresolved is only whether the excess is bigger than the margin I set, and the upper bound misses it by a hair.

Where it fails, and why

The most telling number is not in the table. Twenty one of the confirm repositories have no PMD god class at all. In those, the absolute rule flags 0.9 files per repository. The relative rule flags 6.6.

That is the mechanism, and once you see it, it is obvious. A z score always finds outliers. Every distribution has a tail, so a rule that asks "what stands out here" will name something in a repository where nothing is wrong.

There is also a threshold free reading that rules out the easy fix. Ranking all the units by their per repository z score gives a pooled AUC of 0.903. Ranking them by the raw value gives 0.923, and the difference (minus 0.020, minus 0.033 to minus 0.008) excludes zero. So at any matched false positive rate, the raw value finds slightly more god classes. Turning up the z cutoff to cut the false positives would cost recall and land you at or below the absolute rule. The constant is not the lever.

What I did not do, and did afterwards

The plan had one step that had to happen before the confirm half was read: a check of the oracle itself. PMD runs in the study without the project's compiled classpath, so the parts of GodClass that depend on resolving types (access to foreign data, in particular) see less than they would in a real build. The plan said to build at least ten explore repositories, rerun PMD with their classpath, and bound how many GodClass verdicts flip. Above 10%, the class level verdict could not be read as confirmatory.

I skipped that step, and only noticed after the confirm half had been read. So, by the plan's own rule, the result above is not confirmatory. I ran the check afterwards as a labelled deviation, on explore repositories only, in a fixed order seeded before any build, with every attempt logged and the scripts committed before they ran. It took 29 candidates to get the 10 that built: seven had no usable build or needed an Android SDK, and twelve failed to build under every JDK I tried. Across the 10, giving PMD the classpath flipped 26 of 390 GodClass verdicts, 6.7%, inside the 10% bound. Every flip went the same way: the classpath only ever added a god class, never removed one. Most of them came from one repository, Mindustry, which went from 118 god classes to 141, and 7 of its flips were files the build itself generated, which the study never measured. Without those the rate is nearer 5%. Either way the oracle check passes, and the re-analysis that includes it reads the primary verdict as confirmatory. I still report the result as exploratory, because a check passed after the verdict was read is not the same as one passed before it.

Are the false positives false?

They are false against PMD. And PMD without a classpath does undercount god classes, slightly: every flip in the check added one. So some of what the relative rule flags may be real god classes the oracle misses. If the relative rule's extra flags land disproportionately on the classes that the classpath turns into god classes, part of "flagging too much" is really "the yardstick is short".

The second check is human labels. The MLCQ dataset (Madeyski and Lewowski) has code samples graded by reviewers for Blob and Long Method. The harness scores every definition against those labels, PMD's included, so it can say how well the tool oracle itself agrees with people, and whether the relative rule's extra flags fall on classes people called smelly. That run is next.

Human labels are not ground truth either. The reviewers graded samples without the rest of the repository in view, and their "Blob" is not PMD's GodClass. So the honest conclusion, if it goes that way, will be that the detectors disagree with each other and with reviewers in a particular direction, not that any one of them is right.

The thing I think is actually missing

Here is where I have landed, and it is bigger than this experiment. Every measure in it, PMD's thresholds, the relative rule and the human reviewers alike, looks at one snapshot and judges one class at a time. They are different opinions about the same picture. Without an independent ground truth, their disagreeing tells you that "god class" is not one sharp thing, not which of them is right.

What I actually care about is erosion, and erosion is a process. A god class that has sat unchanged for five years and one that grew by forty percent last year look identical in a snapshot, and only one of them is eroding. The study also flags files one by one, when the interesting property is the shape of the whole distribution: whether a codebase has a heavy tail at all, and whether it is getting heavier. That is exactly where the relative rule breaks. In a codebase with no tail, it invents one. Concentration is the right idea, I think, but a per class outlier is the wrong measure of it: concentration belongs to the codebase, not to the class.

So the next study will not tune this rule. It will try to measure the shape directly, over history rather than at one commit, and judge it by consequences rather than by agreement with other detectors: does it predict where the defects and the expensive changes go? If the relative rule's "false positives" turn out to attract bug fixes, they were not false. It will be pre-registered the same way and drawn on a fresh seed, because the idea for it came from reading this confirm half, and you cannot test an idea on the data that gave it to you.

The short version

A per repository outlier rule identifies PMD's god classes about as well as a tuned absolute threshold, by MCC. It pays for that with roughly twice the false positives, concentrated in codebases that have no god classes at all, because a relative yardstick always finds a tail. The verdict is inconclusive and, because I ran a planned oracle check only after reading it, exploratory, though that check, when it came, passed. The human label comparison will say whether some of those false positives are real. And whether constants fitted to human code still hold for generated code remains the open question that motivated all of this; a measure of concentration that needs no constant is still the way I want to answer it.

The plan, the harness, the sample and the hashes of every analysis are public in the repository, including the commits that show when each was made, and the scripts that ran the check I missed.

Monday, 12 October 2026

Two graphs, not one: the decoupling under the placement result

The paper argues that an architecture does not change how much complexity a problem carries, only where that complexity is placed. The Discussion puts the reason in one line: the composition is the restriction, there is no version of add a rule that concentrates it. That is true. This blog is about why I believe it is true. The short answer is that in OfficeFloor control flow and data flow are two separate graphs.

Where I noticed it

I have been building a diagram tool for IntelliJ that opens an OfficeFloor endpoint and draws it. The surprising part, obvious in hindsight, is that it needs two overlays and not one. One overlay draws control flow, where the next function and the branches direct. The other draws data flow, the variables a function reads and writes and the value a function returns to the next one. They are different shapes over the same set of functions. Building a tool that had to keep them apart made concrete something the experiment had already measured without naming.

One graph or two

In a Spring endpoint the handler method is both a control node and a data node at once. The call graph and the data passing graph are largely the same edges. To get a value from the first step to the fourth you thread it through the second and the third even when they never use it. So control and data are one graph. When the data of the fourth step needs a change, it changes the first three.

Dependency injection softens this. Spring's @Service collaborators are decoupled objects, injected rather than constructed in place.  They provide partial separation and is likely part of why the Spring arm held up as well as it did. What dependency injection does not separate is the orchestration itself, the order the steps run in and the values handed between them, which still lives in the call sequence and its arguments. That is the coupling that remains, and it is the one continuation injection removes.

OfficeFloor keeps the two apart. Sequencing is continuations, the next and the flows, so you splice a step into the order by adding an edge and not by editing the caller. Data is variables, matched by type and qualifier through a shared namespace, so a function sources what it needs and publishes what it produces without any neighbour changing its signature. A rule is therefore a new function and a new edge, and nothing around it moves.

Why that is the mechanism and not a side detail

For add a rule to stay additive, which is the whole of the additive arm's behaviour, the new function has to do two things without touching anything already there. It has to take its place in the order, and it has to get its inputs and hand on its outputs. Those are exactly the two decouplings. Remove either one and add a rule stops being additive.

Two places the numbers already show it

I did not measure the decoupling directly, and the paper does not claim to. But two of its results are fingerprints of it.

The first is the escape from the call graph. Under the disclosed formula the Spring arm's count of container dispatched classes, the aspects, filters and listeners, rose from 3.0 to 11.2 per run. That is the agent reaching for Spring's own decoupling machinery to get complexity off a call graph that resists being spread out, because the call graph couples control and data and will not distribute cleanly. The OfficeFloor arm's count is exactly 1.0 in all five conditions. It never escapes, because the ordinary way it is written is already off the call graph. The flat 1.0 is the decoupling showing up as a relief valve that never has to open.

The second is the wiring the study deliberately does not score. OfficeFloor composes its endpoint in YAML, and the study keeps YAML in a separate pool so a wiring based design cannot dilute a per line metric. That guard is correct, and it cuts both ways, because the composition is where the additive arm puts the structure that makes it additive, and no complexity metric scores it. Read through the mechanism, that guard is the placement result stated at its most literal. The coordination of control and data has not left the system. It has moved out of the Java the call graph metrics can see and into the composition they cannot. The amount is conserved, as the paper shows. What the decoupling changes is where the coordination lives.

This is seen in looking an end point requiring more than one entity:



Decoupling is not the same as granularity

One honest sharpening. The decoupling is necessary for the additive behaviour but it is not the whole of it. You could decouple control from data and still write one large function that reads and writes many variables, which would be decoupled and concentrated at once. So there are two knobs, not one. The decoupling is the substrate, and the granularity, how finely the rules are spread across functions, is the placement that rides on it.

The experiment separates them cleanly, almost by accident. None of the four external instructions moved the additive arm's placement. The fifth lever, an independent AI reviewer reasoning about the code, did, and it did so by merging functions and collapsing edges while leaving coupling, file count and per node complexity untouched. In other words the one lever that moved the additive arm moved its granularity and never its decoupling. The decoupling held under all five conditions. That is the sense in which it is the substrate. It is the part nothing in the experiment could move.

Sunday, 11 October 2026

250 changes most of the damage was in the tests

Part of a series on how software architecture shapes AI driven code degradation. The earlier posts grew one REST endpoint through sixty change requests. This one grows a whole application from nothing through two hundred and fifty.

The question has not changed. When an AI agent keeps changing a codebase, does the code wear down, and does the architecture decide how fast? What changed is the size of the test. Instead of extending an existing server, the agent starts from an almost empty application and builds the whole thing: a database schema, a Spring Boot server and an Angular front end. Then it keeps going, one plain English change request at a time, two hundred and fifty times.

This post is about the first full chain. One architecture, one run, from the first request to the last. The headline numbers from it looked like a disaster. Almost none of that disaster was the agent's.

The setup

The application is a small billing system for a consultancy: clients, projects, invoices, payments, then taxes, discounts, credit notes, currencies, retention, recurring invoices and reports. Each change request is something a user would say, such as "Let me record what a client has paid on an invoice". About three quarters add something new. The rest change something that already exists.

Every request comes with a Playwright test that drives the running application through its UI. The agent sees only that one test. It never sees the earlier tests. After the agent finishes, the harness runs every test written so far, from the first request onwards, as the regression gate. The agent is told what it is being asked for and how its own change will be checked, and nothing about what it might have broken.

whatthis run
stackAngular front end, Spring Boot server, in memory H2 database
change requests250 (186 additions, 64 changes to existing behaviour)
conditiongated: a tool grades each change and asks for a refactor when it scores badly
modelClaude Opus 5.5, a fresh session for every request
cost$121.91 and 2,586 agent turns for the whole chain

The headline that lied

The strict pass rate asks the hardest question: after this change, is every test written so far still green? The run scored 33 out of 247 graded checkpoints. For the first twenty four requests it was perfect. Then it dropped to zero, recovered for ten checkpoints in the nineties, and from checkpoint 106 to the end it never passed again.

Strict pass is all or nothing, so one red test looks the same as a hundred. Counting the tests instead gives a truer feel for it:



The suite grows to 299 tests. At the end 287 pass and 12 fail, and at the median checkpoint only three are failing. The one tall red bar is checkpoint 68, and it is not what it looks like, as the next section shows.

The regression counter was no kinder. It recorded 71 true regressions, meaning tests that passed before a change and failed after it, not counting the ones a request deliberately replaced. Read at face value, the agent was quietly breaking the application as it went.

So every failure was traced back to its cause. Five independent reviews went through all 250 checkpoints: the request, the test, the agent's own transcript, and the code it changed. Every finding was then checked against the application as the agent actually left it.

Where the failures actually came from

At the end of the run twelve earlier tests were failing. This is what caused each one.

failing testscausewhose fault
4 levy testsRequest 244 said "remove the levy entirely", but only declared that it replaced one of the five tests that checked the levy.the test stream
1 stacked discount testRequest 248 went back to one discount per invoice. A test still expected two discount rows.the test stream
1 invoice lifecycle testA replacement test was copied from an old version and clicked a "mark paid" button that request 32 had deliberately removed.the test stream
1 early payment testRequest 164 abolished a fixed number of days. The updated test still seeded a fixed number of days.the test stream
1 top clients testRequest 176 ranked clients by their balance converted to the home currency. The updated test gave it no exchange rates.the test stream
1 savings testTwo discounts seeded into a world that now allowed one. Either reading of the request was defensible.ambiguous
2 settlement testsAt request 106 the agent put a "Credited" badge inside the invoice status cell, so the status read "PAID Credited" instead of "PAID".the agent
1 discount line testAt request 248 the agent removed a discount line from the invoice breakdown that nothing asked it to remove.the agent

Eight of the twelve were defects in the tests, one was a genuine ambiguity, and three were the agent. Those three come from two mistakes, one at request 106 and one at request 248.

The 71 true regressions collapsed the same way. Fifty nine of them came from a single moment at checkpoint 68, when the Java virtual machine crashed seventeen seconds into the gate. Every test after that failed to connect to an application that was no longer there, and each failure was counted against the agent. One more came from a crashed browser worker at checkpoint 146. At checkpoint 58 the test runner itself crashed before writing its report, and the harness quietly scored the previous checkpoint's report in its place.

Across two hundred and fifty changes, the agent made two mistakes that broke something earlier.

Why one small mistake looked like collapse

The strict pass rate did not fall because the agent kept breaking things. It fell because nothing that broke was ever repaired.

The agent is blind to the earlier tests by design, and it starts every request with a fresh session. When the badge went into the status cell at request 106, no later agent could know. The two tests it broke stayed broken for the remaining 144 checkpoints, and that one mistake on its own was enough to hold strict pass at zero to the end of the run.

That is worth saying plainly, because it changes what a long horizon measure measures. Over sixty requests, strict pass mostly tells you how often the agent breaks things. Over two hundred and fifty, it mostly tells you whether anything ever notices. A single unrepaired fault is permanent, and permanent faults accumulate whether or not the code is getting worse.

The tests had subtler problems too

The lasting failures were the obvious defects. The reviews found quieter ones that would have distorted any comparison between architectures.

Checkpoints that asked for nothing. Around fifteen requests asked for something the application already did. The agent ran its test before touching anything, it passed, and the agent changed nothing. Some were the test stream repeating itself: request 220 asked for ageing by due date, which requests 117 and 160 had already established. Others were side effects of earlier choices: Angular's currency pipe had been printing thousands separators since request 9, so request 40's "put commas in" was already done. Counted together with tests too weak to tell the old behaviour from the new, twenty nine checkpoints measured nothing at all.

Coverage that silently disappeared. When a request changes existing behaviour, it ships updated copies of the earlier tests it affects, and from then on the copy replaces the original. Forty three times, a copy left a check out with no request asking for that. "Adding a line item increases the invoice total" vanished from the gate at request 40. A statement that summed every client's invoices would have passed from request 48 on.

Test data the agent could not know about. Each test sets up its own data through a test endpoint that the agent writes and extends as it goes. That endpoint silently ignored any field it did not recognise. When a hidden test seeded a field no visible test had ever used, such as an archived flag, the field was dropped, and the test failed on data it never received. It looked exactly like broken behaviour.

Tests that disagreed with their own requests. Request 173 asked for ageing bands of current, 30, 60 and 90 days, and its test checked three bands. Request 21 said "I do not want to type one figure for an invoice", while request 10's test, still in force, required exactly that form. One test seeded a due date earlier than the issue date, and the agent dropped a database constraint to make it load.

The agent noticed most of it

Thinking blocks were not recorded, but the agent's own summaries were, and they read like a careful contractor's notes. When a request was ambiguous it said so, picked a reading, and named the alternative. At request 176 it wrote that a client whose currency had no exchange rate would no longer appear in the top clients list, and that "the other option would be to list such clients after the ranked ones". At request 201 it ended with "Tell me if it should follow that setting."

It also caught a mistake in the harness itself. The prompt told it to build "an OfficeFloor server" with "a database migration" for every change. The application was plain Spring Boot, and many changes needed no new tables. In roughly half of its two hundred and fifty summaries the agent pointed this out, followed the project's own instructions instead, and listed it as a departure from the request. It was right every time.

The application it built

With the noise removed, the code itself is the more interesting result. After 250 changes the application has around 10,200 lines of Java in 94 files and 12,400 lines of TypeScript in 84 files. The functions stay small, with a mean cyclomatic complexity of 1.5 per function touched. Money is handled properly: BigDecimal throughout, and the billing rules live on the invoice domain object rather than leaking into controllers.



Complexity grew in a straight line, to a total of 1,380 on the server. Where it sat did not run away. The share held by the five heaviest files fell from 0.86 at the start to about 0.43, and stayed there. Average coupling between classes rose quickly while the application took shape and then levelled out at around 8.

The wear is real, but it is concentrated rather than spread. The invoice entity reached 771 lines and 105 methods, because every billing rule from discounts to retention landed on it. The invoice page in the front end reached 1,527 lines. Every real regression in the run happened in that corner of the code. The single most edited function was not application code at all. It was the test data endpoint: a cyclomatic complexity of 102, 341 lines, and 106 edits, growing a little with every request because every test needs its data loaded.

Where the pressure actually fell

The two hundred and fifty requests are written in five acts. The first sets up the entities: clients, projects, invoices, payments. From the second act on, about four in five requests are aimed at one spine, the chain that turns invoice lines into what a client owes: lines, discounts, tax, rounding, the total, payments, the amount still due and the invoice's status. The idea was that change keeps landing on the same code, so pressure builds there and the later acts show how the application copes with it.

That was the plan. This is what the agent's changes actually touched, act by act.

actrequestschanges that touched the invoice corecomplexity added per requestfiles modified per changesettled lines rewrittenchange impact, median percentile
I: the entities1 to 6037%11.85.517%20
II: the calculation deepens61 to 12060%9.56.518%43
III: money over time121 to 18047%9.55.013%52
IV: reports181 to 24010%6.44.313%55
V: the finale241 to 25060%5.05.244%56

"The invoice core" means the invoice entity and its service, its line items and discounts, and the invoice page and service in the front end. "Complexity added per request" is the growth in total cyclomatic complexity across both server and front end, averaged over the act. "Settled lines rewritten" is the share of changed lines that replaced code which had already been there for at least one earlier request. Change impact is the server side grade from the impact tool, as a percentile against its reference distribution.

The pressure peaked in the second act, when six changes in ten went through the invoice core. It eased in the third. Then, for the sixty requests of the fourth act, it almost disappeared. The fourth act was written so that every report would have to re-read the invoice rules, either through the one calculation or by working it out again. In practice the agent built each report as new code that read the data alongside the calculation, not through it. Only one change in ten touched the core. The longest stretch of the run was testing how easily read-only pages could be added, not how a stressed core holds up.

The stress came back in the last ten requests. The two reversals, removing the levy and collapsing stacked discounts, rewrote settled code at more than twice the rate of any earlier act. But nothing came after them. The run ended at the moment it started to test what it was built to test, so the tail behaved like a short burst of change on an already complicated application. That is close to what the earlier experiment did with the Spring PetClinic, which started from a complex application, and not the long horizon this experiment set out to measure.

The amount column tells the same story from the other side. A requirement that adds to the problem should add to the amount of complexity, so across a run of steadily harder requests the amount should climb on a roughly straight line from the first request to the last. For the first three acts it did. Then it bent.


Through the first two acts the application gained about 10 points of cyclomatic complexity per request, and the third act held close to that line. The fourth act added 6.4 per request and the finale 5. The amount kept rising, but more and more slowly: it finished at 2,277 against the 2,604 a steady rate would have reached, 13% short, and nearly all of the gap opened after request 180. The problem stopped growing as fast as the requests kept coming. The earlier paper in this series found that the amount of complexity in a system is fixed by the problem, and that architecture only decides where it is placed. If the problem stops getting harder, there is nothing new to place, and so nothing to negotiate.

Amount is only half of it. The other half is where the complexity went. The point of a spine is concentration: a few entities on one screen, with what that screen does with them growing more and more complicated. Think of a search page or a social feed: one screen with an enormous amount behind it. Here the natural spine is the invoice page, with the invoice and payment entities and the handlers and services behind that page. That is 10 of the 94 server files and 2 of the 84 front end files. To really stress an architecture, most of the complexity should pile up there.


It did not. The spine's share of all complexity peaked at about 27% in the second act and then drifted down, to 20% by the end. In the fourth act only 4% of the new complexity landed on it. The front end shows it most clearly: the invoice page ended up holding 9% of the front end's complexity, because the agent gave almost every new feature a page of its own. The requests asked for "show me", "let me" and "on the home screen", and a new screen was the easiest place to put each one. Rather than piling up on one screen, the pressure spread across more than twenty feature areas.

One more thing in the last column. The change impact grade did climb steadily, from a median of 20 in the first act to 56 by the end, which is the pressure building. But the run was in the gated condition, which asks for a refactor when a change scores above the 98th percentile, and no change in any act scored higher than the 82nd. The gate never fired once. The gated run never asked for a single refactor, so in effect it was an ungated run.

What to aim for

The paper's result sets the target. Complexity cannot be removed from a problem. It can only be placed, and placement is what an architecture actually decides. So the stream has to keep adding to the problem for the whole run, so that new complexity keeps arriving and has to be placed somewhere. And every fourth request has to mutate complexity that is already in place, to test whether the earlier placement still allows change.

It also has to stop being kind to the architecture. The aim of a long horizon run is not to see whether an architecture survives two hundred and fifty requests. It is to find where it breaks, as early as possible, and then see how it behaves when the load keeps coming after that. Stress saved for the last ten requests shows neither.

That leads to four changes in how the stream is written.

One screen, one spine. All two hundred and fifty requests land on a single invoice workspace, with three entities behind it: the client, the invoice and the payment. No request adds a page. Every request makes that one screen, and the few handlers behind it, do more. Three screens would only split the run into three shorter horizons of about eighty requests each.

Overstress early. There is no gentle first act that sets up breadth. The first fifty requests build the spine and load it, so that by request fifty or sixty it is already at the edge. The remaining two hundred show what happens next.

Algorithms in every act. Most of the current requests are the same kind of arithmetic on money. The PetClinic stream also asked for concentrated algorithms, a Luhn check digit, identities derived from SHA-256 hashes, and Soundex matching, and those put dense logic in one place. Each act should add at least three: for example check digits and ISO 11649 payment references, largest-remainder allocation so the cents always add up, annuity amortisation, compound interest over a day count, tiered pricing, tax-inclusive back-calculation, payment matching by string similarity, and duplicate detection by fingerprint. Many of them should later come back as a revised version that changes the result.

Measure the break and what follows it. The amount should climb on a straight line or steeper for the whole run, and at least 80% of each act's new complexity should land on the spine. With one screen and no breadth act it should be well above that. The analysis should report when each break signal first fires, and the trajectory over the next fifty and hundred requests. The signals are: a change past the impact threshold, the spine's handler logic past a complexity limit, the first true regression, effort per request doubling, and rework climbing. The spine has to be measured neutrally about placement. Complexity counts as on the spine if it serves that screen and its entities, whichever classes it lives in, because an additive architecture will put it in new classes rather than old ones. And the impact gate's threshold has to be set where this kind of application can actually reach it, because a gate that never fires is not an intervention.

actrequestson the one workspacealgorithms added (at least 3)
I1 to 50Build the spine fast: lines, discounts, tax, totals, payments, status.Check-digit invoice numbers, ISO 11649 payment references, largest-remainder discount allocation.
II51 to 100Money over time: allocation, deposits, credits, retention, instalments, interest, currency.Annuity amortisation, compound interest over a day count, currency triangulation with per-currency rounding.
III101 to 150Interacting rules: tax by jurisdiction, tiered pricing, rate cards, minimums, cash rounding.Progressive tiered pricing, tax-inclusive back-calculation, cash rounding with distributed differences.
IV151 to 200Cross-cutting states: approvals, credit holds, disputes, recurring with proration, milestones.Calendar proration, remittance matching by string similarity, a risk score from weighted payment history.
V201 to 250Keep loading: credit and rebill, dunning, and revised versions of earlier rules and algorithms.Duplicate fingerprints, a business-day calendar, dunning escalation, revised check digits.

What changed for the next runs

None of these runs are worth comparing across architectures until the measurement is trustworthy, so the harness was repaired before going further. The test stream was repaired too, and those repairs now become rules that the rewritten single-screen stream is checked against before every run.

problemchange
crashed runs scored as resultsThe gate deletes old reports, checks that every test file reported and that the application is still alive afterwards, and retries a broken run once. A failure on a healthy application is never retried, so a real regression cannot be rolled again until it passes.
undeclared and contradictory test changesEvery request that removes behaviour now lists the tests it replaces. Stale and contradictory replacement tests were rebuilt from the version actually in force.
coverage silently droppedDropped checks were restored. A replacement test may now remove a check only if the request declares it retired, and a lint refuses the stream otherwise.
checkpoints that asked for nothingAll twenty nine were strengthened, so each now needs a real change on any reasonable implementation. Each new test was checked against the application as it stood just before that request, and later requests affected by the changed behaviour were updated to match. Every run now also lists any request whose test passed while the agent changed no code, so a request that asks for nothing is caught in the next stream too.
test data the agent never sawThe test endpoint must store every field or refuse it, and must read the data back. Before each test run, the data of every earlier test is replayed and read back, so an agent sees immediately when a change stops storing something an earlier test relies on. It sees the data only, never the earlier tests.
instructions duplicated in every stackThe harness owns one instruction file, AGENTS.md, identical for every architecture: the working rules and the full testing contract. Each stack keeps only its own design notes in DESIGN.md. Nothing in the stacks or the prompt is specific to one AI service.
wrong blame in the reportsA dropped connection or crashed browser is now labelled as infrastructure, not as broken test data or lost behaviour.

What this does not show

Four things, because a write up that only reports what went well is not worth reading.

It is one run. One chain, one architecture, one condition. Nothing here compares Angular with anything else, or the gated condition with an ungated one. The comparisons need ten chains per architecture, and they are still to come.

The numbers are from the old stream. Every figure in this post was measured before the repairs, on a stream that is now being rewritten around a single invoice workspace. The next runs will ask for different things, on one screen, from the first request onwards. Their results will not be comparable with this one, and should not be. This run, and the stream it used, stay in the project's git history for anyone who wants to look back.

The reviews were partly automated. AI reviewers read the transcripts and the tests. That is why every finding that mattered was checked against the real application at the checkpoint concerned: the new tests had to fail before the change they now require, and the checks they keep had to still pass on the application the agent built. It is still a review, not a proof.

A clean stream does not make the agent blameless. Two mistakes in 250 changes is a low rate. Both were the kind a careful human makes too: putting a badge where it changes the text beside it, and tidying away something nobody asked to remove. And one unrepaired mistake still left the application wrong for the rest of the run.

What it means

In a long horizon experiment the test suite is not neutral ground. It grows alongside the code, through the same kind of change requests, and it can wear down in the same ways: duplicated knowledge, stale copies, rules that contradict each other, coverage nobody notices is gone. Here, most of what looked like the agent degrading the application was the measurement degrading instead.

The second lesson is about repair. A blind agent cannot fix what it cannot see, so over a long enough run the dominant effect is not how often things break but how long they stay broken. That is a property of the process around the agent as much as of the agent. The replayed test data is a small first step: it lets the agent notice one class of breakage without seeing the hidden tests.

With the measurement repaired and the stream concentrated on one screen, the next runs can ask the original question properly: when the same two hundred and fifty requests pile onto a single spine in different architectures, where does each one break, how early, and what happens to it after that?

Friday, 9 October 2026

A framework I did not write: testing the plasticity gap across four full stacks

The backend study made a claim that invites one obvious objection. Over a long run of AI written changes it found that a mutative architecture, Spring MVC, lets change concentrate and its structure drift, while an additive architecture, OfficeFloor, holds its placement steady. The objection writes itself. OfficeFloor is mine. I designed it to be additive, so of course it behaves the way I say additive code behaves. A result about an architecture its own author built is a weak result, however clean the numbers are.

The new experiment is built to remove that objection. On the front end it introduces a second additive architecture that I did not write, did not design, and have no stake in: TanStack. Its router and its query layer are composition first. You add a route, a loader, a component, and you wire them in beside what is already there rather than reaching into a shared file that every feature edits. That is additive by someone else's hand. If additive code resists erosion only because I happened to build the additive thing, then TanStack should erode like anything else. If additive code resists erosion because of what additive composition is, then TanStack should hold too. That is the test.

It is worth being precise about who wrote what. On the front end both candidates are third party: TanStack is the additive one, Angular is the mutative one, and I wrote neither. On the back end OfficeFloor is mine and plain Spring Boot is the mutative comparison. So the additive claim no longer rests only on code I authored. The whole front end axis is independent of me, and the additive side of it is a popular framework a lot of teams already ship.

Four stacks, two layers

The study crosses two front ends with two back ends, so every combination of additive and mutative exists at each layer.

OfficeFloor back end (additive) Spring back end (mutative)
TanStack front (additive) additive both ends additive front, mutative back
Angular front (mutative) mutative front, additive back mutative both ends

The application is the same in every cell. It is one small business app, clients, jobs and invoices, grown from almost nothing by the same sequence of plain English change requests. Nothing about the domain or the requests changes between stacks. Only the architecture underneath changes, which is the whole point of treating architecture as the independent variable.

What the cross can show that a single pair cannot

A single additive against mutative pair tells you the two differ. It cannot tell you why. Is the difference a property of the architecture type, additive or mutative, wherever it sits in the stack, or do the front and the back influence each other so that the combination matters more than either layer alone? One pair cannot separate those. A two by two can.

Three questions fall straight out of the grid.

  • Does a mutative front end drift whether the back end is additive or mutative? That is the Angular row read across.
  • Does a mutative back end drift whether the front end is additive or mutative? That is the Spring column read down.
  • When additive meets mutative across the stack, do the two layers simply add up, or does one drag the other? That is the off diagonal.

If plasticity is purely a property of the type, the prediction is tidy. The additive cell stays flat, the mutative cell drifts, and the two mixed cells land in between in proportion to how much mutative code each carries. If instead the layers influence each other, the mixed cells will not sit where simple addition predicts. A mutative back end might pull an additive front out of shape as the front keeps reaching across to it, or a disciplined additive front might hold a messy back end partly in check. Either way the off diagonal is where that would show, and it is exactly the cell a single pair never gets to measure.

Why a whole stack and why 250 changes

The backend study changed one server behind a fixed API. This one grows a whole application, the database schema, the server and the user interface together, from a near empty start, one plain English request at a time. The tests are the only thing held steady through all of it. They drive the running app through named test anchors and assert on what a user sees, so the entire stack beneath them can be rewritten and the same test still judges it. The coding agent works blind, seeing only the current request and its own test, never the ones that came before, so it cannot pattern match its way through the sequence.

And it runs for 250 changes rather than 60. The backend study could not decide between two readings of its own result, that concentration genuinely compounds into damage over a long horizon, or that the effect is real but mild, because sixty changes on one endpoint may simply be too short to tell. A longer horizon is the way to settle that. The changes are also deliberately concentrated on one part of the app, the invoice and money logic, so pressure piles up in one place instead of spreading thinly across many screens where nothing ever gets hard. At the end of each act a fresh reader with no memory is asked to read the code cold and list every rule the money logic now applies, which gives a running measure of how completely and how cheaply the accumulated logic can still be understood.

What would prove me wrong

The experiment is set up to report any of several outcomes, not just the one I expect. If TanStack erodes like Angular, then the additive claim was really an OfficeFloor claim, and the general version does not survive contact with a framework I did not write. If the mixed stacks land exactly where simple addition predicts, then the layers are independent and there is nothing cross cutting to find. If nothing has pulled apart by 250 changes, then the horizon is still too short or the effect is mild after all. Each of those is a real result and each is one the harness will show plainly.

As with the rest of the series, the harness, the per checkpoint commits and the raw capture for every chain are public, and the whole study re-runs against any model. The stacks are built, the first full run is starting, and I will post the numbers as the chains finish.

Test Harness

The test harness is here: https://github.com/officefloor/ui-long-degradation-test

The four stacks are:

Wednesday, 7 October 2026

Speaking at ChurConf Sydney: explicit function orchestration and the data behind it

A short note. I am speaking at ChurConf in Sydney in November, and the talk rests on an experiment I have just finished and written up.



The talk

Code Your AI Agent Can Actually Read: Explicit Function Orchestration in Spring Boot.

The argument is that how you structure a Spring Boot application decides how legible it is, to a person and now to an AI agent, and that making the orchestration of functions explicit rather than leaving it implicit in a mutative controller changes what an agent can safely do to the code. The talk shows explicit function orchestration inside Spring Boot and why that placement of logic holds up when an agent starts changing it.

  • When: 18 to 19 November 2026
  • Where: Stone and Chalk Tech Central, Sydney
  • Conference: au.churconf.com

The data behind it

The talk is not an opinion piece. I have just finished an experiment and published the paper, so the claims come with empirical backing.

An AI agent implemented sixty accumulating change requests on one REST endpoint, under two architectures, a mutative Spring controller and an additive function injection pipeline, with the model held fixed and ten runs per cell. The question was simple. When you change what the agent is told, does the structure of the code move?

The finding is that the amount of complexity is conserved. The finished codebases carry roughly the same amount of complexity whichever architecture they use and whatever the agent was told. What moves is placement, where that complexity sits, and the two architectures do not move alike. Under every external lever I tried, from a bare change request up to handing the agent the disclosed structural cost function, the mutative controller's placement moved while the function injection pipeline's placement did not. On the placement metrics the mutative arm travelled between about three and ten times as far as the additive arm.

That is the point I want to land. The function injection architecture is not perturbed by prompting. Its placement of logic is set at the structural level, by the architecture, not at the level of whatever last prompted the agent. Prompting pushes a mutative controller around. It does not push an additive pipeline around, because there is nowhere for the pushing to land. Architecture is the lever, not the prompt.

The one thing that did move the additive arm was a second AI agent reviewing each change and reasoning about cohesion itself, and it moved it on one family of metrics only, with the cost reported in full. That is the other half of the paper's title.

Read it

  • The paper, Conserved amount, negotiable placement. Prompting perturbs one architecture, an AI reviewer both, is published with DOI 10.5281/zenodo.23190619.
  • It is the latest in a series on this blog about how software architecture shapes AI driven code degradation.
  • The harness, the per checkpoint commits, and the raw capture for every run are public, and the whole study re-runs against any model with one flag.

If you are at ChurConf, come and find me. This is the work the talk is built on, and I am happy to discuss any of it in person.

Tuesday, 6 October 2026

Metrics updated to include AI review

The fifth condition, the independent AI reviewer, is now folded into every metric figure in the series. I did not want to republish the whole metric gallery as a second wave of posts just for the AI review. As it's the same (or corrected data for the four conditions), the images in the earlier metric posts are updated to include this fifth condition.

See last month's blog posts for them.