Saturday, 31 October 2026

The same long method under function injection: how OfficeFloor changes the split cost

SinewSlim prices a long method by what crosses each cut: the parameters and the return value. That formula assumes the pieces are methods the rest of the method calls. Under function injection they aren't. They're functions the framework calls in sequence, and it supplies their arguments. This post puts the two formulas side by side on the same method, shows where OfficeFloor makes the split cheaper, and shows where it makes it dearer.

Recap: the conventional formula

From the previous post, a piece extracted as a method:

seam(piece) = inputs + outputs           outputs ≤ 1 (a record costs 3 per value)
cost(plan)  = Σ ( seam(piece) + 1 )
feasible    = every piece AND the remainder ≤ 25 lines,
              no return / break / continue leaving a piece

The best plan is the one with the cheapest worst seam. If there is no feasible plan, the method can't be slimmed.

Every term in that formula comes from one assumption: the caller supplies everything. The remainder has to hold each value and pass it in, take the return value back, and decide what runs next. Function injection removes that assumption, and each term changes.

The same method, as functions

Here's the checkout method from the last post, with its pricing and charging written as OfficeFloor functions. This is a sketch, trimmed to the shape that matters:

public class Checkout {

    @Next("charge")
    public static void price(Cart cart, Customer customer, Out<BigDecimal> total) {
        BigDecimal subtotal = ...;
        BigDecimal discount = ...;
        BigDecimal tax = ...;
        total.set(subtotal.subtract(discount).add(tax));
    }

    @Next("receipt")
    public static void charge(@Val BigDecimal total, Customer customer,
            PaymentGateway gateway, Out<Payment> payment) {
        Payment result = gateway.charge(customer.card(), total);
        if (!result.ok()) {
            throw new PaymentFailed(result);    // an escalation, handled where it is wired
        }
        payment.set(result);
    }
}

Three things are different from extracting methods:

  • Nobody passes cart, customer or gateway. Each function declares what it needs, and OfficeFloor injects it.
  • Nothing returns. A value one function produces is a variable (Out<T>) that a later function declares (@Val). There's no single-return limit, and nothing to unpack.
  • There's no remainder. @Next and the wiring decide what runs next, not a method holding the calls.

The formula, term by term

Inputs: injected values are free. Anything that comes from outside the method (its own parameters, or anything the framework manages, like gateway) costs nothing, because the function simply declares it. Only a value one of the new functions computes and a later one uses still costs. It costs 1 in every function that declares it, because each declaration is something a reader has to follow:

conventional:   inputs = every value the piece uses from before it
OfficeFloor:    inputs = only the values an earlier function computed, 1 per declaring function

Outputs: no single return, no records. A function can hand on as many values as it likes. A value isn't charged where it's produced, but where it's used: it costs 1 in each later function that declares it, as above, so nothing is counted twice:

conventional:   outputs ≤ 1, or a record at 3 per value
OfficeFloor:    any number of outputs; each is paid for by the functions that declare it

Jumps: most are no longer blockers. A return in the middle becomes the end of a function, followed by a @Next or a flow to the next one. A throw becomes an escalation, handled wherever it's wired. break and continue still block, because a loop body doesn't become a flow without spawning. The two models agree there.

conventional:   feasible only if no return / break / continue leaves the piece
OfficeFloor:    return → @Next or a flow;  throw → escalation;  break / continue still block

Branches: a new kind of cut. Conventionally, an if moves whole or its insides move. Under OfficeFloor an if or switch can become a decision function, with each branch a flow to its own function. That kind of cut doesn't exist when a remainder calls the pieces.

The remainder disappears. Conventionally the remainder must fit under 25 lines too, holding one call line per piece. Under OfficeFloor each function must fit under the cap, but there's no method left over holding the calls.

The wiring is paid for. Conventionally a piece costs its seam plus 1 for its name and call line. Under OfficeFloor a function costs its seam plus 1 for its name, and every edge between functions (a @Next or a flow) costs 1 more. Without that, OfficeFloor would come out cheaper just by leaving the wiring out of the sum. Put together:

seam(function) = values it declares that an earlier function computed
cost(plan)     = Σ ( seam(function) + 1 ) + edges

The two side by side

Conventional (extract method)OfficeFloor (function injection)
Seaminputs + outputsvalues declared that an earlier function computed
Outputsat most 1, or a record at 3 per valueany number, paid for where they're declared
Cost per piece1: a name and a call line1 for the function, plus 1 per edge
Must fit under 25every piece and the remainderevery function
return mid-methodblocks the cutends a function: @Next or a flow
throwmoves with the codean escalation
break / continueblocks the cutblocks the cut
if / switchmoves wholecan be a decision function with a flow per branch
Shape of the resulta tree: the remainder calls the piecesa graph: the framework sequences the functions

The worked example, priced both ways

Under OfficeFloor there's no remainder, so the end of the method (building the receipt and mailing it) becomes a third function, receipt. The guard clause moves into price, where an empty cart goes down its own flow instead of returning.

PieceConventional seamOfficeFloor seam
pricingcart, customer in; total out → 3cart, customer injected → 0
charginggateway, customer, total in; payment out → 4total declared; customer, gateway injected → 1
receiptstays in the remaindertotal, payment declared; cart, customer, mailer injected → 2
worst seam42
total cost(3 + 1) + (4 + 1) = 9(0 + 1) + (1 + 1) + (2 + 1) + 3 edges = 9

The three edges are price → charge, charge → receipt, and the empty-cart flow. If the payment failure needs a new handler wired for its escalation, that's one more edge, making 10.

So the worst seam halves, from 4 to 2, but the total stays about the same. Here OfficeFloor doesn't make the method cheaper overall. It spreads the cost evenly, so no single function is hard to follow, and it pays for that with wiring and a third function the conventional plan didn't need. The bigger change is the guard clause. Conventionally, that early return Receipt.empty() pins the top of the method to the remainder. Under OfficeFloor it becomes a flow, and the method's structure stops fighting the split.

Where OfficeFloor costs more

It isn't free, and a comparison that only shows savings isn't worth much:

  • Wiring. Every function is a node someone has to wire, and every @Next or flow is an edge to keep correct. A conventional extraction is one call line in a method you're already reading.
  • Values carried a long way. A value produced early and used by several later functions is declared in each one. If a method passes a lot of computed state from step to step, the OfficeFloor seams don't shrink much, and the wiring is added on top.
  • Loops. A loop whose body is too long still has to be split inside the loop, the conventional way.
  • Getting there. In a Spring codebase, the method's beans have to be made available to OfficeFloor, transactions need a boundary, and exceptions need handlers. That's a migration cost the seam formula doesn't count, and an honest comparison has to report it beside the seam.

Why I'm keeping the two formulas apart

SinewSlim already has a "framework reading" that re-weights its formula: passed values are free and outputs are unlimited. It's a useful first look, but it's still the conventional search with a remainder calling pieces. It can't express "this if becomes a decision function with two flows", however it's weighted.

So the OfficeFloor model is becoming its own tool, OfficeFloorSlim. It reads the same facts as SinewSlim, but it searches differently: it partitions the method's control flow into connected functions, rather than lifting pieces out of a remainder. SinewSlim stays a neutral measure of conventional code and never mentions OfficeFloor. The comparison is computed in OfficeFloorSlim, beside SinewSlim's number, so neither one is tuned to flatter the other.

The numbers in this post come from one method I chose to show the difference. Whether the difference holds across real codebases is a question for measurement, not for a blog post. That's the empirical study now under way: the same applications built with Spring and with OfficeFloor, measured as they grow. The paper will carry the method and the statistics, including every case where OfficeFloor comes out worse.

SinewSlim: what it costs to make a long method short

A long method is easy to find. Knowing whether it can be fixed is the hard part. SinewSlim answers that with one small formula: count what crosses the seam when you cut the method in two. This post walks through that formula from a developer's point of view, with a worked example, what the tool does with the answer, and where it stops.

Where the 26 comes from

I'm not going to re-argue what a Long Method is. That was settled in the study behind What reviewers call a Long Method is a length. Plain length, at 26 code lines, picked out the methods reviewers flagged as Long Methods better than anything else I tried. Complexity, coupling and variable counts all came in below it. So a method of 26 code lines or more is long, and that is the trigger.

Once that's settled, "is it long?" stops being the interesting question. The useful question is: what does it cost to make it short? Some long methods are just a sequence of steps, and splitting them takes a few minutes. Others have the same handful of variables touched on every line, and there's no clean place to cut. Both are 40 lines long. They are not the same problem.

The sinew

When you extract a run of statements into its own method, every variable crossing the boundary becomes interface:

  • used inside the run and set before it → a parameter
  • set inside the run and used after it → the return value (and Java gives you one)
  • used only inside the run → nothing; it moves with the code

That data crossing the cut is what I call the sinew. A long method is a muscle grown too big. You don't want to cut through the sinew; you want to slim the method by moving whole runs of statements out where the least sinew crosses.

The formula

For one extracted piece, the cost of its seam is what crosses it:

seam(piece) = inputs + outputs

Every parameter costs 1 and the return value costs 1. A plan for a method is a set of pieces, and it costs:

cost(plan) = Σ ( seam(piece) + 1 )      the +1 is the new name and call line

A plan only counts if it actually fixes the method. Every piece, and what's left of the original method (the remainder), must end at 25 code lines or fewer.

Among the plans that work, SinewSlim picks:

  1. the smallest worst seam: the plan whose most expensive cut is the cheapest;
  2. then the fewest pieces;
  3. then the smallest total cost.

The worst seam comes first on purpose. One extracted method that takes five parameters is worse for the next developer than three that take two each. The worst seam is the method's slim cost, the single number SinewSlim reports.

What a piece is allowed to be

Java puts limits on what a piece can be, so the formula comes with rules. With the defaults:

RuleDefaultWhy
Return values per pieceat most 1Java has one return
Parameters per pieceat most 5a hard ceiling; more than that is not a fix
Jumps leaving the piecenone (a trailing return is fine)a return, break or continue in the middle can't move into another method
Smallest piece3 linesbelow that a call saves almost nothing
Largest piece25 lines, after its own pieces are outthe piece must be under the cap too

Two refinements are worth knowing about:

  • A soft parameter limit (optional). Set soft_inputs: 3 and each parameter beyond three costs 2 extra. A 4-parameter piece then costs 6, so the search takes it only when every 3-parameter route costs more.
  • Records. If every way to fix the method needs a piece returning two values, SinewSlim offers a record instead, at 3 per value. That reflects what the developer pays: a record type to name, the call, and an unpacking line per value left behind.

A worked example

Here's a checkout method, abbreviated. The real one is 38 lines, mostly in the pricing.

public Receipt checkout(Cart cart, Customer customer, PaymentGateway gateway, Mailer mailer) {
    if (cart.isEmpty()) {
        return Receipt.empty();
    }

    // pricing: 14 lines of subtotal, loyalty discount and tax
    BigDecimal subtotal = ...;
    BigDecimal discount = ...;
    BigDecimal tax = ...;
    BigDecimal total = subtotal.subtract(discount).add(tax);

    // charging: 8 lines
    Payment payment = gateway.charge(customer.card(), total);
    if (!payment.ok()) {
        throw new PaymentFailed(payment);
    }
    ...

    Receipt receipt = new Receipt(cart, total, payment);
    mailer.send(customer.email(), receipt);
    return receipt;
}

Price the two obvious cuts:

PieceParametersReturnsSeam
pricingcart, customertotal2 + 1 = 3
charginggateway, customer, totalpayment3 + 1 = 4

subtotal, discount and tax are used only inside the pricing, so they move with it and cost nothing. The throw is fine: an exception leaves the new method the same way it left the old one. Extracting both pieces leaves a remainder of around 18 lines, so the plan works. Its slim cost is 4 and its total cost is (3 + 1) + (4 + 1) = 9.

Now notice the guard clause at the top. That return Receipt.empty() is in the middle of the method, so any run that includes it can't be extracted. Here it's harmless, because it's short and stays in the remainder. When the early returns are spread through the long part of a method, though, they block every cut.

What SinewSlim tells you

Every method gets one of three answers:

  • Under the cap. Nothing to do.
  • Slimmable, with the pieces, what each takes and returns, and the cost. In the IntelliJ plugin each piece has a button that runs the IDE's own Extract Method and asks you only for a name.
  • Not slimmable, with the reason. The tool lifts its limits one at a time to find which one blocks: two or more return values, too many parameters, jumps, one statement longer than the cap by itself, or a method so long that it needs splitting in two passes.

The third answer matters most. A method that can't be slimmed is a design problem, and every change to it makes it harder to take apart later. On OfficeFloor's own code (21,994 methods), 106 methods can't be slimmed, and for 79 of them the reason is jumps: early returns, or a switch full of returns.

There's a fourth case, reported as a warning. If every piece writes to the object's fields or calls the class's own methods, the method can be split, but nothing actually leaves the class. That's a god method, and the class is what needs fixing, not the method.

What it doesn't measure

Being straight about the limits:

  • It costs interface, not meaning. A cheap seam can still be the wrong thing to name. If a piece doesn't make sense as a method, skip it and the tool finds the next plan.
  • Fields are not counted as interface. A piece that only reads fields is free in the formula, but it's tied to the class. The tool reports that as the piece's binding (static, static if the fields are passed in, or instance) rather than folding it into the cost.
  • It reads syntax, not types. There's no symbol solver, which is why it's fast enough for a git hook, and why a few edge cases need the IDE to finish the job on splitting.

Trying it

SinewSlim is on GitHub, Apache 2.0. It runs as a command-line scan of a whole codebase, as a GitHub Action that reviews the methods a pull request touches, as a git hook, and as an IntelliJ plugin. One .sinew-slim.yml drives all of them, so they never disagree about a method. When it's set to block, it only blocks a change that pushes a method over the cap or makes a long method longer, so it can be switched on in a codebase that already has long methods.

What reviewers call a Long Method is a length

I ran a study to test an idea behind OfficeFloor: that coupling is what separates a god class from a merely complicated one. The idea failed the test. What the data showed instead was much simpler and more useful. When professional reviewers call a method a Long Method, they are responding to its length, and the line sits at about 26 code lines. This post explains the study for developers: what I tested, what failed, what came out of it, and what it means for the code you review.

The paper is What reviewers call a Long Method is a length. It has the full method and statistics. This post covers what matters if you write and review code.

What I set out to test

An earlier study of mine (the companion paper) flagged a class or method when its complexity was unusually high for its own codebase. It found where the complexity was concentrated, but it couldn't tell two things apart:

  • a god unit: a class or method that directs everything around it and also holds the logic;
  • a complex algorithm: a class or method that is hard inside, but just processes what it's given.

Both look complicated. The difference, I thought, was in the coupling. A method that hands data on to lots of other code is directing traffic. A method that receives data and works on it is a leaf. So I proposed weighting complexity by what a unit hands on minus what it receives, so that leaves are set aside and the traffic directors stand out.

This idea matters to me personally. OfficeFloor is built on taking data off the call chain. If coupling explained god units, that would have supported the framework's premise. So I pre-registered the test: the measure, the data, the pass mark and the statistics were written down and committed before any coupling was measured on the data used to judge it. That way I couldn't adjust the test after seeing which way it went.

The data

The labels come from MLCQ, a public data set in which professional developers reviewed classes and methods from Java open-source projects and said whether each one was a code smell, such as a Blob (god class) or a Long Method. I measured 520 of those repositories, plus 500 fresh Java repositories and 73 systems from the Qualitas Corpus, which carry a second set of human god-class labels.

The repositories were split in half before anything was measured. One half, explore, is where choices are made. The other half, confirm, is only used to check them. A number chosen on one half and checked on the other is far less likely to be a fluke of the data.

Agreement is reported as MCC (Matthews correlation coefficient). If you haven't met it: it scores how well a yes/no rule matches the labels, from 0 (no better than chance) to 1 (perfect). Unlike plain accuracy, you can't score well by always saying "no" when smells are rare. For a sense of scale, machine-learning classifiers trained on these same labels reach a median MCC of about 0.81 for Long Method.

The idea failed

Against the reviewers' god classes, weighting complexity by coupling did not beat complexity alone. The difference was −0.041, and the plausible range ran from −0.130 to +0.054, so no difference was shown either way. The test was also underpowered: MLCQ didn't have enough repositories with a reviewed god class to show a small effect.

What was clear went against the idea:

  • Without picking a threshold, the coupling-weighted form ranked god classes worse than complexity alone.
  • Weighting by plain size (the number of methods in the class) beat weighting by coupling, by a margin the data could resolve. A god class, to these reviewers, looks more like a big class with one complex method than a hub of coupling.
  • Only one part of the idea held up: among complex methods, leaves were called smells less often. Even that narrowed later, as you'll see below.

I'm reporting this plainly because it's the result I didn't want. A study that can only confirm its author's framework isn't worth reading.

What the data did settle: a Long Method is a length

MLCQ keeps every individual review, not just the majority verdict. Once the registered test was done, I looked at what the reviewers actually do. Here are 3,054 Long Method reviews by 26 reviewers, grouped by the method's code lines (blank lines and comments not counted):

Code linesCalled a Long Method (calls other project code)Called a Long Method (leaf)
1–101.0%0.4%
11–2524.3%17.4%
26–5081.6%80.0%
51–10094.0%94.1%

There's a step at about 25 lines. Below it, most reviewers let a method go. Above it, four out of five call it long. Three things stand out:

  • It's the same line for everyone. Each of the ten reviewers with at least ten Long Method calls has a best cutoff between 17 and 34 lines (median 21), whichever project they were reviewing. The line varies a little from reviewer to reviewer, but not from codebase to codebase. That's why my earlier "unusual for this codebase" rule could never match it.
  • Leaf or not doesn't matter. At 26–50 lines, a method that calls nothing else in the project is called long 80.0% of the time, against 81.6% for one that does. Reviewers judge the length, not the coupling.
  • Where leaves do get a pass, it's narrow. Among the methods that cognitive complexity flags, the leaves are called long less often (63% against 83%), at about the same median length. Reviewers are a little more forgiving of a branchy method that calls nothing else in the project. That's the only exemption the data support.

Why 26

I treated the cutoff the same way as everything else: chosen on one half, checked on the other. On the explore half I tried nine round cutoffs (10, 15, 20, 26, 30, 40, 50, 75 and 100 lines). 26 scored best there (MCC 0.715, against 0.710 at 20 and 0.681 at 30).

On the confirm half, which played no part in the choice, "26 code lines or more" flags 131 of 1,033 reviewed methods. 103 of those are ones the reviewers called long, and it finds 82% of all the methods they called long. That's an MCC of 0.777, from one number with no training. The classifiers trained on these labels sit around 0.81.

Don't read too much into the exact 26. On the confirm half the score is flat from 20 lines (0.761) to 30 (0.778), and has fallen by 40 (0.644). The honest summary is "mid-twenties". 26 is the cutoff the procedure picked, and it's as good as anything in that range.

Nothing inside the method does better

The obvious objection is that length is a crude proxy. Surely the real thing is how many variables you have to keep in your head, or how tangled the data flow is? I tested that too.

  • 34 measures built from the method's variables: how many are live at each line, counts of locals, parameters and fields, and the data flow from inputs to calls. Every one did worse than plain length, by a margin the data could resolve. The best reached 0.673.
  • The cost of splitting the method: the parameters and return values its pieces would have to pass, the formula SinewSlim now uses. It came close (0.764) but didn't beat length. Among long methods it didn't pick out which ones the reviewers called long, either.

So nothing measured inside a method's body tells you which long methods reviewers let through. They are counting lines, and a line count is the right detector for what they call a Long Method.

What this means for you

  • You can stop arguing about the threshold. A cap in the mid-twenties of code lines matches what professional reviewers call a Long Method. It doesn't need to be tuned per project, and it doesn't need a complexity metric alongside it.
  • "Is it long?" is the boring question. Once length is settled, the useful question is how hard the method is to bring back under the cap. Two 40-line methods can be very different problems: one splits into three clean pieces, while the other has the same five variables on every line.
  • The cap is about people. 26 lines is a limit of human reading. Code that agents write and maintain doesn't have to respect it for the agent's sake, but people still review it. The cost of keeping it readable depends on how the code is structured, and that's where architecture comes in.

That second point is why I built SinewSlim (link to the SinewSlim post): it takes 26 as given and prices the cut. The third point leads to the follow-up, which compares the same cut under conventional calls and under OfficeFloor's function injection.

Limits

  • The length finding is exploration, not a registered test. It came after the registered analysis had been read. The confirm half is an independent check of the choice among the nine cutoffs, but I picked those nine after looking at the reviews.
  • Java open source, and one data set. MLCQ's reviewers are professional developers, but they're reviewing open-source Java, and half the confirm repositories sit under ten owners. Other languages and closed codebases may differ.
  • Conflict of interest. I created OfficeFloor, and a positive result would have suited me. It came out negative, and the paper says so.

Everything needed to re-run it is public: the plan with its dated amendments, the samples, the converted labels and the scripts, with the outputs and their digests. A replication doesn't need anything from me.

What must AI think of us?

So us humans have a limit of 26 lines before we consider it "big" or "godly". An AI with a 1M context minus some for processing overhead, can consider about 50,000 to 75,000 lines of code.  It leaves me thinking, are we humans the "junior" developers trying to code review the "senior" AI developer?

Furthermore, I'm thinking we need to be building the foundations on which we can trust AI, such as the mathematics behind sinews to mathematically restrict AI from eroding the system.

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?