Tuesday, 4 August 2026

Measuring AI-driven code degradation

Methods companion · Measurement reference Spring OfficeFloor

Measuring AI-driven code degradation

A companion to the results paper. Every metric in the PetClinic-Evolve harness, defined precisely. What it computes, where the number comes from, and what it reads high on.

1

Why the measurement comes first

A degradation study is only as good as its metrics. If the numbers are vague, the conclusion is vague. This companion defines each metric the harness records, so a reader can check the results against exactly what was measured. The results paper reports what the numbers did. This paper says what the numbers are.

Two design choices shape everything below. Both exist to make a per-change measurement trustworthy.

The agent delta is isolated. Each checkpoint produces two commits. The first is the agent commit. Its diff against its parent is exactly what the AI changed, and nothing else. The second is a reset commit that re-normalizes the tree for the next step. Any metric described as "of a change" is computed on that agent diff. So blast radius, coupling, and churn measure the agent's edit, not harness bookkeeping.

Capture now, derive later. A run stores only raw data on the branches. The agent envelope. The raw pass or fail of every test. The committed source at each step. Every metric below is recomputed offline from that history. So a metric can be defined or corrected after a run and re-applied to it, with no new agent cost. Every number was produced this way.

2

Ground rules for the structural metrics

Four conventions apply to every structural metric in Section 3.

  • Production Java only. Structural metrics ignore test code, build files, and configuration. Java under the main source tree is counted. OfficeFloor's YAML wiring is counted separately and never mixed into a Java denominator.
  • Per-function CC and SLOC. A static analyzer (lizard) reports two numbers per function. CC is cyclomatic complexity, the count of independent paths through the function. SLOC is source lines of code. These two feed most of what follows.
  • The complexity threshold is 10. A function is "complex" when its CC exceeds 10. This is the standard Radon threshold, applied identically to both arms.
  • The dynamic subsystem. Some metrics run over the whole application. Others run over the "touched subsystem": every production function in a file changed since the chain's base commit. Scoping this way follows the evolving footprint, and it automatically includes any new class the agent creates. So a growing hotspot cannot hide in a fixed file list.
3

Structural metrics

Erosion ratio · whole-app and scoped

mass(f) = CC(f) × √SLOC(f)
Erosion = ΣCC(f) > 10 mass(f)  ÷  Σall f mass(f)
Measures
How concentrated complexity is. The share of total complexity "mass" that sits in functions above the CC threshold.
Source
lizard CC and SLOC over production Java. Reported twice: over the whole app, and over the touched subsystem (erosion_scoped).
Reads high
When a few heavy functions hold most of the complexity.
In this run
wash Both arms near identical. A ratio normalizes away the concentration it targets. See Section 6.

Verbosity ratio

Verbosity = | CloneLines ∪ PatternLines |  ÷  LOC
Measures
Duplicated and boilerplate-flagged lines as a share of code.
Source
Clone lines from a copy-paste detector (jscpd, strict mode). Pattern lines from a structural search (ast-grep) run against a rule set of wasteful Java idioms. The two line sets are unioned, then divided by lines of code.
Reads high
When code is repeated or padded with boilerplate. Note the index can exceed 1.0, because a clone counts lines on both sides of the pair. It is an index, not a fraction.
In this run
separates OfficeFloor higher at every phase, from its many similar small functions. Neither arm's verbosity grows.

Blast radius per-change family

existing_fns_modified = # already-present functions whose body the diff touched
files_created = # new production files  ·  churn = production lines added / removed
Measures
How much pre-existing code a single change disturbs, and whether it adds or mutates.
Source
The agent diff. Hunk headers (git diff -U0) give the changed line ranges. lizard gives each function's line range. A function counts as modified when a changed range overlaps its body, and its file existed before the change. New files are counted as created.
Reads high
existing_fns_modified is high when a change reaches into working code. files_created is high when a change adds new units instead.
In this run
separates Spring's existing_fns_modified slope excludes zero; OfficeFloor's is flat and it creates ~19 files per chain.

Hotspot CC worst function

hotspot_cc = maxf in touched subsystem CC(f)
Measures
The single most complex production function in the evolving footprint, reported with its name.
Source
lizard over the touched subsystem. The function with the highest CC, ties broken by SLOC.
Reads high
When one function is becoming a god-method.
In this run
overlap Both arms rise; Spring higher, but the confidence intervals overlap.

Weighted Methods per Class (WMC) worst class

WMC(class) = Σmethods m in class CC(m)
wmc_max = maxclass WMC(class)
Measures
The god-class indicator. The heaviest class by total method complexity.
Source
lizard functions grouped by file (one top-level class per Java file), summed per class, maximized over classes in the touched subsystem.
Reads high
When one class carries many rules. This catches what erosion misses: a class that stays tidy method by method while accumulating many methods.
In this run
overlap Both rise; Spring's final WMC is ~2× OfficeFloor's, but the slopes overlap.

Entry-handler CC the front door

entry_cc = CC( f ) where f matches the arm's create-entry pattern
Measures
The complexity of the one function the create endpoint routes through.
Source
An arm-specific regular expression over "file::function". Spring matches OwnerRestControllerV1::addOwner. OfficeFloor matches its designated create-entry, BuildOwner::service. lizard gives the CC.
Reads high
When the single handler absorbs each new rule rather than delegating it.
In this run
separates cleanly Spring +0.25 per checkpoint, OfficeFloor +0.06. The confidence intervals do not overlap. Final CC 6.7 vs 1.9.

Change spread reach

packages_touched = # distinct package directories the diff's production Java touches
Measures
How far across the package tree a single change reaches. A sibling to blast radius, measured by directories rather than functions.
Source
The agent diff. Distinct parent directories of the changed production Java files.
Reads high
When a change is scattered across many packages.
In this run
both near zero Neither arm's spread trends.

Re-edit rate temporal coupling

for each function the change edits, blame its whole body at this commit
re-edit rate = ( body lines authored by an earlier checkpoint ) ÷ ( total edited-function body lines )
Measures
When a change edits existing code, how much of that code earlier rules wrote.
Source
For every production function the change modifies, git blame attributes each body line to the checkpoint that last wrote it. A line counts as "prior" when its author is a checkpoint after the base but before this one. Whole function bodies are counted on purpose. A one-line insert into a large shared method still signals coupling, and line-of-diff blame would miss it.
Reads high
When new rules keep reopening functions that earlier rules grew.
In this run
separates cleanly Spring's slope is positive, OfficeFloor's is negative. The intervals do not overlap. The sign of the coupling trend flips between the architectures.

Function-package stats OfficeFloor-specific

fn_count, fn_nloc_avg, fn_nloc_max, fn_cc_max over the wired-function package
Measures
The size distribution of OfficeFloor's composed-function package.
Source
lizard over a configured package glob (the rest/function tree).
Reads high
The healthy-growth signal is a rising count while the average and maximum function size stay flat. That is addition without bloat.
In this run
Count rises steadily; per-function size stays flat. Consistent with add-not-mutate.

Lines of code size, reported separately

java_loc = production Java SLOC  ·  yaml_loc = OfficeFloor wiring lines
Measures
Raw size. Kept as context, and as the denominator for verbosity.
Source
lizard for Java. A non-blank non-comment line count for YAML.
Note
YAML is never folded into a Java ratio. It is reported on its own so OfficeFloor's habit of spreading logic into wiring cannot distort a Java metric.
4

Correctness metrics

Correctness is scored from the raw pass or fail of every acceptance test, captured at run time. The tests are black-box. They hit the REST API, so both arms are judged by identical externals.

The test taxonomy. There is one test class per checkpoint, named CpNN, tagged so the harness runs checkpoints one through K at checkpoint K. The method-name prefix encodes a category: core, error, or functionality. A test whose own checkpoint is earlier than K counts as a Regression test at K, whatever its category.

Strict / ISO / Core pass correctness gates

strict = all selected tests pass  ·  iso = this checkpoint's own tests pass  ·  core = the core tests pass
Measures
Three tiers of "did it work". Strict is everything. ISO ignores the regression tests and asks only whether this checkpoint's new behavior works. Core asks only the essential path.
Reads high
All three are 1.0 when the checkpoint fully satisfies its suite.
In this run
Strict pass was 1.0 in every phase, both arms.

Normalized Change SWE-CI · range -1 to 1

if passed ≥ baseline:  (passed − baseline) ÷ (target − baseline)
else:  (passed − baseline) ÷ baseline
Measures
Net progress in passing tests from one checkpoint to the next, on a signed scale.
Source
The passing-test set before and after the checkpoint. "baseline" is the prior passing count, "target" the total selected.
Reads
Positive for improvement, negative for regression. The definition is asymmetric on purpose. It punishes a regression harder than it rewards an equal-sized improvement.

Regressions count

regressions = | passing_before − passing_after |
Measures
How many tests that passed before the checkpoint fail after it.
Reads high
When a change breaks previously working behavior. This is the direct safety signal.
In this run
no difference Zero regressions in either arm across 200 checkpoints each.

EvoScore SWE-CI · discounted success

per chain:  ( Σi γi · si ) ÷ ( Σi γi ),  then averaged over chains
Measures
Success across a whole chain, weighted by checkpoint. si is the strict-pass indicator at checkpoint i.
Source
The strict-pass sequence per chain. The discount γ is a parameter; γ ≥ 1 rewards staying green late in the run, when the codebase is largest.
In this run
1.0 at every γ for both arms.

Zero-Regression Rate across chains

ZRR = ( # chains with zero total regressions ) ÷ ( # chains )
Measures
The share of full runs that never broke anything.
Reads high
1.0 means every chain stayed regression-free end to end.
In this run
no difference 1.000 for both arms.
5

Process metrics

These come straight from the agent session envelope, captured at run time. They cannot be recomputed later, so they are stored raw.

Cost, tokens, and duration the agent envelope

cost_usd
Dollar cost of the checkpoint's agent session.
tokens
Input, output, cache-read, and cache-creation token counts. Cache-read is a proxy for how much prior context the model re-read.
num_turns
Tool-use turns the agent took to finish.
duration_ms
Wall-clock time, including tool runs and any waits.
duration_api_ms
Model inference time only. The cleaner "thinking cost" signal.
attempts
Every attempt is recorded, including failed or rate-limited ones and their wait time, so true cost and wall-clock are recoverable.
In this run
Cost and API time fell slightly over each run in both arms. The "comprehension gets more expensive" idea did not appear at this scale.
6

The statistic: degradation slope

A single metric at a single checkpoint is noise. The signal is the trend across the run. The harness reduces each metric to one number per arm, with an interval.

Degradation slope m the headline number

m = OLS slope of ( metric value vs checkpoint index )
reported on the mean curve, with a 95% bootstrap CI resampled over chains
Mean curve
At each checkpoint, average the metric across the ten chains. Fit a straight line to that averaged curve. Its slope is m.
Confidence interval
Resample the ten chains with replacement, 2,000 times. Refit m each time. The 2.5 and 97.5 percentiles are the 95% interval. The interval reflects chain-to-chain variability.
Phase means
Checkpoints one to twenty are also binned into five phases, Start to Final, to show the trajectory in a small table.
How to read a slope A metric separates the two architectures when one arm's interval excludes zero and the other's brackets it. It separates strongly when the two arms' intervals do not overlap. A rising slope with an interval that stays clear of zero is the degradation signal. An interval that straddles zero is a flat metric.

The one caveat that matters. Not every metric can see every difference. Erosion is a whole-application ratio. When one arm adds many small functions, it inflates the denominator at the same time the other arm concentrates complexity in the numerator. Both effects move the ratio the same way, so the ratio cancels the difference. The lesson generalizes. For architectural degradation under AI-driven change, lead with un-normalized, per-change statistics such as blast radius, entry-handler complexity, and re-edit rate. Treat aggregate ratios as secondary. The headline number should be one that can discriminate.

7

Controls that keep a number honest

Three controls protect the metrics from being gamed or contaminated. They are part of the measurement, not a footnote to it.

  • Experimenter-owned tests, reset before scoring. The acceptance tests are black-box and reset to their authored version before the correctness gate runs. An agent that weakens a test cannot produce a false pass. Any edit it makes is recorded, then reverted.
  • A pinned project guide. The leveling document is restored to its base version after every checkpoint, so it can never become accumulating memory across checkpoints.
  • Provenance and a config snapshot. Each run records the model, the tool versions, the agent environment, and a snapshot of the exact metric configuration used. So a metric recomputed later uses the same definitions the run was scored under.

In this run the agent never edited a pinned document and never tampered with a test, across all 400 sessions. The controls were never triggered. Their presence still removes two ways the numbers could have been wrong.


R

References and notes

  1. SlopCodeBench · arXiv:2603.24755. Source of the Erosion and Verbosity definitions, the degradation-slope statistic, and the prompt-intervention arms.
  2. SWE-CI · arXiv:2603.03823. Source of Normalized Change, EvoScore, and Zero-Regression Rate.
  3. Weighted Methods per Class follows the Chidamber and Kemerer object-oriented metric suite.
  4. OfficeFloor · officefloor.net. The graph-of-functions framework used in the OfficeFloor arm.

Companion. The results, with confidence intervals and the full slope table, are in "Architecture as the independent variable". This paper defines the instruments. That paper reports the readings.

PetClinic-Evolve · metrics reference · claude-opus-4-8 · every definition matches the harness implementation and is recomputed from the committed run data.

Architecture as the independent variable

Preprint · Empirical software engineering Spring @RestController OfficeFloor functions

Architecture as the independent variable

Hold the AI coding agent fixed for 400 sessions. Vary only the architecture it works in. Does the codebase degrade because of the agent? Or because of the shape it is asked to grow?

Abstract

AI coding agents now take on long, iterative work. A worry has followed. Codebases seem to degrade under sustained agentic change. That degradation is usually studied as a property of the agent. We ask a different question. Is it a property of the architecture the agent is asked to extend?

We built a harness that holds the agent fixed. Each step gets a fresh, context-free claude-opus-4-8 session. Architecture is the only thing that varies. The same twenty owner-creation rules were added, checkpoint by checkpoint, to two forks of one REST application. One fork is a conventional Spring @RestController. The other is an OfficeFloor pipeline of YAML-composed functions. Ten chains per arm, times twenty checkpoints, gives 400 agent sessions. Every metric is re-derived from the commit history. Each slope carries a 95% bootstrap confidence interval over chains.

The result is sharp in one direction and null in another. How change is absorbed separates the two architectures cleanly. In the Spring arm, per-change blast radius, create-endpoint complexity, and temporal coupling all rise. Their confidence intervals exclude zero. In the OfficeFloor arm they stay flat, and it keeps adding small isolated units. Yet the benchmark's nominal "erosion" ratio does not separate the arms at all. It was meant to be the decisive statistic. It was not. And neither architecture produced a single test regression across 200 checkpoints each. Architecture governs the mechanism of degradation. At this horizon it has not yet changed the observed correctness. We report what held, what washed out, and why the deciding statistic was not the one we set out to measure.

1

The question

A coding agent asked to add one small feature usually does fine. The concern is the hundredth small feature. Iterative, agent-driven extension may leave a codebase quietly worse. More tangled. More concentrated. More expensive to keep changing. Anecdotes are common. Controlled measurement is rare.

Most attempts treat the agent as the variable. Different models. Different prompts. Different scaffolds. The task stays fixed. That tells you which agent degrades a codebase least. It cannot tell you something a system designer also needs to know. Does the architecture itself change how much damage accumulates?

So we invert the design. We hold the agent constant. Architecture becomes the independent variable. We take two implementations of one application. We feed both the same feature requirements, one checkpoint at a time. We watch how each absorbs twenty rounds of accumulating change. If degradation is purely the agent's fault, the arms should degrade alike. If architecture is a lever, they should diverge. The shape of the divergence then tells us which architectural properties matter.

Contribution A reproducible harness that isolates architecture as the cause of AI-driven code degradation. The agent, the task, the tests, and the leveling documentation are held fixed across both arms. It ships with a full result set, with confidence intervals, on which structural properties separate the architectures and which do not.
2

Results at a glance

We track thirteen metrics. The arms separate on the ones that describe how a change touches the codebase. They refuse to separate on the aggregate "erosion" ratio and on correctness. Here is the verdict, one dimension at a time.

◆ Separates cleanly

Blast radius

Spring's per-change edits to existing functions grow. The slope CI excludes zero. OfficeFloor's is flat. Spring disturbs twice the existing surface.

◆ Separates cleanly

Create-endpoint complexity

Spring's single addOwner handler bloats to CC 6.7. OfficeFloor's create-entry stays at 1.9. The CIs do not overlap.

◆ Separates cleanly

Temporal coupling

Spring keeps re-opening earlier rules' code. Its slope is positive. OfficeFloor's re-edit rate falls. The CIs do not overlap.

◆ Separates cleanly

Add vs. mutate

OfficeFloor creates about 19 new files per chain. Spring creates about 1. The two architectures absorb a new rule in opposite ways.

≈ Washes out

Erosion ratio

The nominal decisive metric. Both arms end near 0.012. Both CIs touch zero. The whole-app ratio cannot see the difference.

✕ No difference

Correctness & safety

Zero regressions. Full strict-pass. EvoScore 1.0. In both arms. Erosion stayed latent. It never broke anything at this horizon.

How to read the rest of this paper. Section 3 is the method. It covers the arms, the task, the fixed agent, the metrics, and the statistics. Section 4 is the evidence. It follows the six verdicts above. One figure anchors it. That figure is a coefficient plot of every degradation slope with its confidence interval. Sections 5 to 7 discuss why the designated statistic failed, the threats to validity, and how to reproduce every number.

3

Method

3.1  Two arms, one application

Both arms fork the same REST implementation of the Spring PetClinic sample. They share a data model, a database schema, and one black-box HTTP contract. They differ only in how request-handling logic is structured.

  • Spring. The conventional layered style. New behavior for POST /api/owners is added by editing a Spring @RestController method and the service beneath it.
  • OfficeFloor. A graph-of-functions style. The create-owner endpoint is a pipeline wired in YAML. New behavior is added by writing a new small function class and wiring it in.

The two base branches are matched. The first checkpoint starts each arm from an equivalent, working application. Everything downstream of the architecture is held identical. That means the requirements, the acceptance tests, the agent, the model, and a fixed project guide.

3.2  The task: twenty accumulating rules

Each checkpoint adds one requirement to the owner-creation endpoint. Every requirement stays in force for all later checkpoints. The rules are ordinary business logic. Duplicate detection by household, telephone, and email. Telephone and city-name normalization. Derived fields such as initials, customer code, membership number and tier, namesake count, and locality. A default registration date. A shared-household flag. City-capacity limits. An audit trail with a bulk-signup warning. By checkpoint twenty, one POST endpoint must enforce twenty interacting rules. That is exactly the kind of accreting hotspot where architecture should start to matter.

3.3  The fixed agent

At every checkpoint the harness launches a fresh, headless claude-opus-4-8 session. It gets only that checkpoint's specification. No conversation history. No memory of earlier checkpoints. No resume. The condition is deliberate. The agent must reason from the code's current structure alone. That is the setting in which a self-describing architecture can pay off. It also removes carried context as a confound. The agent runs with the same tools and the same neutral just-solve prompt in both arms. The prompt says to implement the change so all tests pass.

3.4  Isolation and anti-gaming

Three controls keep the comparison honest. They stop the agent from inflating its own score.

  • Experimenter-owned acceptance tests. Each checkpoint's tests are black-box. They hit the REST API. The harness injects them and resets them to the authored version before the correctness gate runs. So an agent that weakens a test cannot produce a false pass. Any edit the agent makes to a test is recorded, then reverted.
  • A pinned project guide. A fixed CLAUDE.md sits in both arms. It is restored to its base version after every checkpoint. It can never become cross-checkpoint memory.
  • A two-commit history. Each checkpoint makes an agent commit. Its diff is exactly what the agent changed. A reset commit follows. It re-normalizes the tree and stages the next checkpoint's test. Isolating the agent's true delta is what makes the structural metrics measurable per change.

Across all 400 sessions the agent edited a pinned document 0 times. It tampered with an acceptance test 0 times. The controls were never exercised. Their presence still removes two obvious threats to validity.

3.5  Capture now, derive later

A run stores only raw capture on the branches. That capture is the part that cannot be recomputed. It holds the agent's cost, tokens, and full event stream. It holds the raw pass/fail map of every test. It holds the true pre-normalization diff. It holds a provenance manifest with the model, tool versions, and a config snapshot. Every derived metric is recomputed later from the commit history. That covers erosion, verbosity, blast radius, coupling, and the rest. The split has a payoff. A metric can be defined or fixed long after a run and re-applied to it, with no new agent cost. Every number in this paper came from that offline re-derivation over the committed trees.

3.6  What we measure

Structural metrics are computed over production Java only. The same tools and thresholds apply to both arms.

  • Erosion. The share of total complexity mass that sits in high-complexity functions. Mass is cyclomatic complexity times the square root of SLOC, per function. High means above the standard CC = 10 threshold. This is the benchmark's headline statistic.
  • Verbosity. Flagged clone lines and anti-pattern lines, per line of code.
  • Blast radius. For a checkpoint's change: how many already-existing functions it modifies, and how many new files it adds.
  • Weighted Methods per Class. The god-class indicator. The highest per-class sum of method complexity.
  • Entry-handler complexity. The cyclomatic complexity of the one function the create endpoint routes through. In Spring that is addOwner. In OfficeFloor it is the designated create-entry.
  • Temporal coupling, or re-edit rate. When a checkpoint edits an existing function, the share of that function's body written by earlier checkpoints. High means new rules keep re-opening old ones.
  • Process and correctness. Cost, tokens, and API duration. Then Strict, ISO, and Core pass, Normalized Change, regressions, EvoScore, and Zero-Regression Rate.

3.7  Statistics

The degradation slope m is the OLS slope of a metric against checkpoint index within a chain. We report the mean-curve m with a 95% bootstrap confidence interval. The interval is resampled over the ten chains, with 2,000 replicates. A metric separates the arms when one arm's slope CI excludes zero and the other's brackets it. It separates more strongly when the two arms' CIs do not overlap. The scale is 10 chains, times 20 checkpoints, times 2 arms. That is 400 agent sessions. Each slope is fed by 200 checkpoints per arm.

Positionality and conflict of interest. OfficeFloor is authored by the experimenter. The OfficeFloor arm's base was purpose-built for this comparison. We mitigate this several ways. Identical requirements. Identical black-box tests, scored by an anti-gaming gate. A fixed agent. Fully published commit histories, so every number is independently recomputable. A reader should still weight the framing accordingly. See section 6.

4

Results

4.1  The degradation slopes, at a glance

Figure 1 plots the degradation slope and its 95% interval for every metric where the arms behave informatively. The metrics have different units. So each row is scaled to its own range. Within a row, two things matter. Does an interval cross the zero line? Do the two arms' intervals overlap?

Spring OfficeFloor ● slope estimate · ▬ 95% CI · ┆ zero tick · each row scaled independently
Figure 1. Degradation slopes with 95% bootstrap CIs. Four metrics describe how change lands: blast radius, files created, entry-handler CC, and re-edit rate. They separate the arms. Spring's disturbance and coupling rise. OfficeFloor's stay flat or fall. The two aggregate complexity slopes, hotspot CC and WMC, both rise but overlap. Erosion is the nominal decisive statistic. It is a wash. Both slopes hug zero.

4.2  How a change lands: the arms separate

The clearest signal is blast radius. It measures how much pre-existing code a single new rule disturbs. In Spring it climbs over the run. The slope is +0.052, CI 0.016 to 0.084. That excludes zero. In OfficeFloor it is statistically flat. Aggregated, Spring modifies about twice the existing surface per chain. It almost never gets away with touching nothing. OfficeFloor takes the zero-blast path more than a third of the time. It adds a rule without re-opening any existing function.

Table 1. Blast radius per chain (200 checkpoints per arm)
SignalSpringOfficeFloorReading
Existing functions modified / chain55.828.2Spring 2× the disturbance
Zero-blast checkpoints11/20070/200OF touches nothing 35% of the time
New files created / chain1.218.7add vs. mutate
Line churn / chain+500 / −22+662 / −30OF adds more lines, in new units

This is the mechanism in one table. A new rule in Spring is folded into existing methods. The same rule in OfficeFloor is attached as a new function. A cross-check from the agents' own event streams makes it concrete. Over the first five chains, the OfficeFloor agents wrote 75 distinct function and handler classes. That is a whole rest/function/owner/ package. Think AssignMembershipTier, CheckCityCapacity, AuditOwnerCreation. Spring wrote 20. It folded the same logic into its controller and service.

The consequence shows up in two places. The create endpoint. And temporal coupling. These are the cleanest separations in the study. Here the arms' confidence intervals do not even overlap.

Table 2. Concentration and coupling (final values and slopes)
MetricSpringOfficeFloorSlope CIs overlap?
Entry-handler CC (final)6.71.9no, clean split
Entry-handler CC slope m [CI]+0.25 [.14,.38]+0.06 [.05,.07]Spring bloats ~4× faster
Re-edit rate (mean)0.3310.216
Re-edit rate slope m [CI]+0.011 [.005,.016]−0.024 [−.030,−.018]no, opposite signs
God-class WMC (final)64.535.7Spring's worst class ~2× heavier

Spring's single create handler grows about 4× faster than OfficeFloor's. It ends more than three times as complex. And its re-edit rate rises while OfficeFloor's falls. So as the run proceeds, Spring keeps re-opening code written for earlier rules. OfficeFloor increasingly leaves earlier rules alone. The sign of the coupling slope flips between the two architectures. That is the erosion thesis in its strongest, cleanest form. It is just not measured by the erosion metric.

4.3  The designated statistic washes out

The benchmark's headline metric is erosion. It is the fraction of whole-app complexity mass held in high-complexity functions. It was meant to be the decisive number. It is not. Both arms' slopes hug zero. Both confidence intervals include zero. The phase-binned means converge on nearly the same final value.

Table 3. Erosion, phase-binned mean (Start to Final)
ArmStartEarlyMidLateFinalslope m [CI]
Spring0000.00360.0128.0007 [0,.0017]
OfficeFloor00.00590.00570.00520.0121.0006 [0,.0014]

The reason is structural, and it is instructive. Erosion is a ratio over the whole application. OfficeFloor adds many small functions. That inflates the denominator, the total complexity mass. At the same time, Spring concentrates complexity in the numerator, a few high-complexity functions. Both effects push the ratio the same way. Against a roughly 280-function application, one bloating controller barely moves it. So the metric meant to be decisive is the one metric blind to the difference. It normalizes away the very concentration it was trying to detect. The discriminating statistics are the un-normalized, per-change ones. Blast-radius slope. Entry-handler CC. Re-edit rate.

4.4  Verbosity: a real cost, but a constant one

With clone and pattern detection restored, verbosity gives an honest counterpoint. OfficeFloor carries more duplication than Spring at every phase. Its 75-odd small function classes share near-identical scaffolding. The clone detector flags it. But neither arm's verbosity grows. Both slopes are slightly negative.

Table 4. Verbosity index, phase-binned mean (flagged lines ÷ LOC)
ArmStartEarlyMidLateFinalslope m [CI]
OfficeFloor1.201.191.161.141.12−.0052 [−.0054,−.0049]
Spring1.081.071.041.020.99−.0058 [−.0061,−.0054]

This exposes the real cost of the OfficeFloor strategy. Isolation-by-proliferation pays a constant boilerplate tax. There is more repeated scaffolding up front. In exchange, the per-change blast radius stays bounded and does not compound. Spring pays the opposite way. Little duplication, but an entanglement cost that keeps rising. One note on the numbers. The verbosity index goes above 1.0 because clone lines count both sides of each pair. It is an index, not a fraction.

4.5  No correctness difference, and cost falls

Two outcomes people expect to move did not. Both arms are perfectly green. Strict-pass is 1.0 in every phase. EvoScore is 1.0 at every discount factor. The Zero-Regression Rate is 1.000. No checkpoint in either arm broke a previously passing test, across 200 checkpoints. The structural erosion is entirely latent. It accumulates. At this twenty-checkpoint horizon it never bit. Per-change cost also fell slightly over each run, in both arms. The slope is about −0.008 USD per checkpoint. That is the opposite of the idea that comprehension gets more expensive as the hotspot grows. Prompt-cache reuse and the agent's own efficiency dominate any rising-comprehension effect at this scale.

The finding in one sentence Architecture cleanly governs how AI-driven change is absorbed. Spring's disturbance and coupling grow. OfficeFloor's stay flat. But at twenty checkpoints that difference in mechanism has not yet changed correctness. And the aggregate erosion ratio is too coarse to see it.
5

Discussion

5.1  Architecture is a real lever, on the mechanism

The strong reading is not that OfficeFloor is better. It is that the two architectures put an agent's accumulating changes in structurally different places. And that this is measurable, with tight confidence intervals. Spring absorbs each rule by mutating and re-coupling existing units. So blast radius, create-endpoint complexity, and temporal coupling all trend up. OfficeFloor absorbs each rule by adding an isolated unit. So those same quantities stay flat. The cost is more boilerplate. There is a practical takeaway for anyone building a system that AI agents will maintain over a long horizon. The variable that separated is the one to manage. Keep the per-change blast radius bounded. Then the compounding costs, coupling and hotspot concentration, never get a foothold.

5.2  Choose the right decisive statistic

Our most transferable finding is a caution about metrics. The pre-designated decisive metric was the whole-application erosion ratio. It is the wrong instrument for this question. Normalization cancels the concentration it targets. Studies of architectural degradation under AI-driven change should lead with un-normalized, per-change statistics. Blast-radius slope. Entry-point complexity. Temporal coupling. Treat aggregate ratios as secondary. The headline number should be the one that can discriminate.

5.3  Latent vs. manifest degradation

Zero regressions in either arm is itself a result. It says Spring's structural entanglement is latent risk. It is not yet realized failure. Not at twenty checkpoints, against this acceptance suite. So the "OfficeFloor is safer" claim, in the sense of fewer broken tests, is unsupported by this run. It stays an argument about maintainability and future change-cost. The structural metrics support that. They do not speak to observed correctness. Surfacing a correctness difference would need more. A longer horizon. A harder or more interdependent test suite. Or a task where the rules conflict, rather than merely accumulate.

6

Threats to validity

  • Conflict of interest. OfficeFloor is the experimenter's framework. Its arm's base was purpose-built. Identical requirements, black-box anti-gaming tests, a fixed agent, and published commit histories mitigate the risk. They do not remove framing bias.
  • Single agent, single strategy. One model, claude-opus-4-8. One neutral prompt, just-solve. The harness supports intervention arms, anti-slop and plan-first, and other models. None are run here. Results may not carry across agents.
  • Single domain and hotspot. One application. One accreting endpoint. One family of business rules. Architectures that look different on a create-owner pipeline may converge on other workloads.
  • Horizon. Twenty checkpoints separated the mechanism. It did not manifest a correctness or cost difference. The most interesting effects may live past this horizon.
  • Metric construct validity. Erosion's normalization limit is covered above. The verbosity index double-counts clone pairs. Structural metrics recomputed offline depend on the analysis-time versions of the complexity and clone tools. Provenance pins them, but they are not identical to run-time.
  • Independence. Chains are independent by construction. Each starts a fresh worktree from the base. But both arms ran from one machine and one quota window. Systematic model drift over the run would affect both arms alike.
7

Reproducibility

Every result here is recomputable from published artifacts. Each of the twenty chains is a git branch. It is named evolve/<run>/just-solve/<arm>/chain<n>. Per checkpoint, its history is an agent commit and a reset commit. A final commit carries the raw capture, a provenance manifest, and a snapshot of the exact analysis configuration used. The analysis tool re-derives every metric, including any added later. It materializes each checkpoint commit and recomputes over its source. So the tables above regenerate on demand. New metrics apply to the same run. Neither step re-invokes the agent.

Appendix A below lists the complete degradation-slope table for all thirteen tracked metrics, both arms, with 95% CIs. It is the full data behind Figure 1.
8

Conclusion

We held the coding agent fixed and varied only architecture. Architecture is a real lever on how AI-driven change accumulates. Across 400 sessions, the Spring arm's per-change blast radius, create-endpoint complexity, and temporal coupling rise. Their confidence intervals exclude zero. The OfficeFloor arm's stay flat. The difference is not visible in an aggregate erosion ratio. That ratio cannot see it. It is visible in the un-normalized statistics of how each change lands. That divergence in mechanism did not become a correctness difference at this horizon. Both arms stayed perfectly green. The next questions are about horizon and generality. Longer runs. Conflicting rather than accumulating requirements. More architectures. More agents. The aim is to learn whether the latent entanglement one architecture builds up eventually becomes the failures the other avoids.


A

Appendix. Full slope table

Degradation slope m (OLS on checkpoint index) with 95% bootstrap CI over 10 chains
MetricSpring  m [CI]OfficeFloor  m [CI]Separates
Structural. How change lands
existing_fns_modified+0.0523 [.016,.084]+0.0056 [−.009,.023]yes
files_created−0.0021 [−.0037,−.0005]+0.0208 [.008,.030]yes
entry_cc+0.252 [.137,.381]+0.062 [.048,.068]yes
reedit_rate+0.0107 [.005,.016]−0.0244 [−.030,−.018]yes
Structural. Aggregate
hotspot_cc+0.241 [.163,.343]+0.172 [.102,.243]overlap
wmc_max+1.77 [1.38,2.06]+1.44 [1.36,1.49]overlap
packages_touched+0.0092 [−.001,.017]−0.0011 [−.007,.005]both ~0
erosion+0.0007 [0,.0017]+0.0006 [0,.0014]wash
erosion_scoped+0.0021 [0,.0051]+0.0014 [0,.0032]wash
verbosity−0.0058 [−.006,−.005]−0.0052 [−.005,−.005]level, not slope
Process
cost_usd−0.0082 [−.015,−.002]−0.0079 [−.015,−.001]both fall
duration_api_ms−1948 [−3552,−424]−1267 [−2379,−151]both fall
cache_read_tokens−1643 [−8867,4647]−4600 [−10060,−99]noisy

Correctness held constant across both arms and all phases. Strict-pass 1.0. EvoScore 1.0 at γ = 1, 1.5, 2. Zero-Regression Rate 1.000. Pinned-doc touch 0/200. Acceptance-tamper 0/200.

R

References and notes

  1. SlopCodeBench · arXiv:2603.24755. The no-context iterative-extension benchmark. The harness borrows its Erosion and Verbosity metric definitions, the degradation-slope statistic, and the prompt-intervention arms.
  2. SWE-CI · arXiv:2603.03823. The CI-gate evaluation framework. The harness borrows Normalized Change, EvoScore, and Zero-Regression Rate.
  3. spring-petclinic-rest. The REST PetClinic sample application both arms fork from.
  4. OfficeFloor · officefloor.net. The graph-of-functions application framework used in the OfficeFloor arm.
PetClinic-Evolve · 400 agent sessions · claude-opus-4-8 · run 2026-08 · figures and tables generated from the committed run data.
Draft for a blog and preprint. Palette and figures designed for the subject. Validated for colour-vision-deficiency separation.

Thursday, 30 July 2026

Initial findings on multiple chained changes

Provisional. These are early numbers from a run in progress. Two Spring chains and one OfficeFloor chain are complete, and a second OfficeFloor chain is still going. Every figure below will be refreshed once more runs land, and the values are likely to move. Read the direction of the findings, not the exact numbers.

This is a progress report. It shares early data from a longer running experiment. The numbers here are real but the sample is still small. I will update as more runs land.

The question

Does software architecture change how code decays when requirements keep arriving?

The common worry with AI coding is drift. You ask for one more rule, then another, then twenty more. The endpoint that started clean slowly turns into a swamp. I wanted to test whether the choice of architecture changes that outcome. Not the agent. Not the model. Just the architecture.

For previous experiments driving to ask this question, see here.

The two applications

Both applications come from the Spring PetClinic REST reference application. A fork lives in the OfficeFloor GitHub at https://github.com/officefloor/spring-petclinic-rest. It has two branches. The spring-compare branch is the standard Spring layering. The officefloor-compare branch is the OfficeFloor version. They expose the same REST API. They pass the same tests. They differ in how the code is wired together.

See how the two projects were create here.

How the experiment works

The design borrows from two papers. SlopCodeBench measures how code quality erodes when a model extends its own work with no shared context. SWE-CI measures change through a continuous integration gate, so only passing work counts.

The setup holds everything fixed except the architecture.

  • One agent does all the work. It is Claude Opus. The same model on both arms.
  • One endpoint evolves. It is POST /api/owners. No new endpoints are added.
  • Twenty checkpoints. Each checkpoint adds one business rule to that endpoint. Examples include duplicate detection, unique telephone, a derived membership number, a per day signup cap, a per city capacity limit, and an audit trail.
  • The agent gets no memory between checkpoints. Each step starts cold. This mimics the real world, where the person changing the code is rarely the person who wrote it.
  • A black box acceptance suite judges both arms by identical externals. Tests create owners and read fields back over HTTP. The internal design is never assumed.

Every checkpoint is committed to its own branch. Anyone can review the exact code the agent produced at each step. Each run of twenty checkpoints is called a chain.

What has finished so far

Three chains are complete. Two are Spring. One is OfficeFloor. A second OfficeFloor chain is still running. So the Spring result is replicated and the OfficeFloor result is a single run for now. Read the numbers with that in mind.

Finding 1. Both arms are correct

Across the three complete chains, every checkpoint passed. That is sixty checkpoints out of sixty. Strict, core, and isolation checks all green. Neither architecture broke an earlier rule while adding a later one. Zero regression on both sides.

So the interesting differences are not about correctness. They are about shape and cost.

Finding 2. Classic erosion did not appear

The first metric was erosion. It measures the share of code that sits inside heavy functions, where heavy means a cyclomatic complexity above ten. A god method would light this up.

It stayed at zero on every chain. The busiest single function reached a complexity of seven on Spring and eight on OfficeFloor. Neither agent ever wrote a god method. The model factors logic into small helpers no matter which architecture it works in. So this metric could not tell the two arms apart.

That is a useful result on its own. The decay people fear is not a single bloated method here. It shows up somewhere else.

Finding 3. The real difference is fan out

The two arms diverge sharply in how the work spreads across files. Here is the state at the twentieth checkpoint.

ArmFiles touchedFunctionsFunctions per file
Spring chain 035819.3
Spring chain 136120.3
OfficeFloor chain 025602.4

Both arms end with about sixty functions. Spring packs them into three files. OfficeFloor spreads them across twenty five. The two Spring chains landed on almost the same shape, so this is stable, not luck.

The trajectory of files touched tells the story checkpoint by checkpoint.

Spring       1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3
OfficeFloor  3, 3, 3, 5, 6, 7, 7, 8, 9, 10, 12, 13, 16, 19, 20, 21, 22, 23, 24, 25

Spring settles on three files by the sixth checkpoint and never grows again. Every new rule is added inside those same files. OfficeFloor grows almost one file per rule. Each rule tends to become its own small wired function.

Finding 4. Spring grows the controller, not the service

This is the headline. Where does Spring put all that logic?

The base application ships the standard PetClinic layering. There is a service layer with a ClinicService interface and a ClinicServiceImpl class. That is the intended home for business rules.

The service layer was never touched. Not once. On either Spring chain. Across all twenty checkpoints.

The REST controller absorbed the work instead. At the twentieth checkpoint the controller holds about seventy six percent of the changed code. The rest is mostly derived fields added to the data model, such as a display name and a set of initials. So Spring did not build a god method. It built a god class. The controller quietly became the business logic layer.

LayerFunctionsShare of changed code
REST controller2675.7%
Data model3123.4%
Mapper10.9%
Service layer00%

This is why the fan out numbers look the way they do. Spring pins at three files because everything piles into the controller. OfficeFloor spreads because each rule attaches as its own function, wired into the flow rather than stacked in one place.

The decay is not a bloated method. It is a layer leak. The logic climbs up into the controller and settles there.

Finding 5. Adding a rule rarely disturbs existing code on OfficeFloor

This is the measure that matters most for AI maintenance. When a new rule arrives, how much working code does the agent have to reach into and change? Change that lands in existing functions is risky. Change that lands as a new isolated unit is safe. This is the blast radius of a change.

I counted, for each checkpoint, how many pre existing functions the agent modified, and how many brand new files it created instead. Summed across the twenty rules of a chain.

ArmExisting functions modifiedNew files created
Spring (mean of two chains)560
OfficeFloor2422

Spring adds every rule by editing existing code. It created no new files at all and reached into about fifty six existing functions over the chain. OfficeFloor disturbed less than half as many existing functions, twenty four, and stood up twenty two new files to hold the new logic. OfficeFloor adds behaviour by addition. Spring adds behaviour by modification.

The clearest way to see it is to count the checkpoints where the agent touched no existing function at all. A rule that lands with zero blast radius changed nothing that already worked.

  • OfficeFloor added eight of the twenty rules with zero blast radius.
  • Spring managed that for only two, and both were trivial field additions.

It also does not ease off. Late in the chain Spring is still modifying four or five existing functions per rule. The distributed shape lets OfficeFloor keep attaching new rules without reopening old ones.

There is an honest trade here. OfficeFloor writes more new code to do this, because a new wired function carries its own wiring. So the total lines added are higher. The point is where the change lands, not how much is typed. OfficeFloor keeps the change away from working code.

Finding 6. Comprehension was about even

At several checkpoints a fresh agent with no prior context was asked to explain the system. The score is how much of the expected behaviour it recovered. Both arms rose as the system grew. OfficeFloor held a slight edge at the final checkpoint.

What this means so far

The story is not the one I first went looking for. I expected a god method and rising complexity on Spring. That did not happen. The agent is too tidy at the method level for that.

What did happen is a layering story. On Spring the business rules drift up into the controller and the service layer sits idle. On OfficeFloor the rules stay distributed as small wired functions. Same behaviour. Same test results. Very different shape.

Which shape you prefer is a judgement call. A concentrated controller is fewer files to open. A distributed set of functions is smaller pieces to reason about and change in isolation.

The blast radius numbers push that judgement in a clear direction for AI work. A machine that adds each rule as new code, without editing the functions that already pass their tests, is a machine that is harder to let break something. On OfficeFloor the agent could do that eight times out of twenty. On Spring almost never. That is the strongest signal in the data so far.

Caveats

  • The sample is small. Two Spring chains and one OfficeFloor chain are complete. A second OfficeFloor chain is still running.
  • One agent and one model produced all of this. A different model may behave differently.
  • The comprehension edge needs the remaining OfficeFloor runs before I would call it stable.

Next steps

Blast radius and the controller share are now recorded at every checkpoint by the harness, so the next runs will chart them directly. From here it is more chains on each arm, to firm up the fan out, blast radius, and comprehension numbers with a larger sample.

Monday, 27 July 2026

Can a composed function architecture give AI a better map of your code?

I took the Spring PetClinic REST reference application and built it two ways. One uses conventional Spring @RestController methods. The other uses OfficeFloor endpoints declared in YAML as composed functions. Then I pointed the same AI agent at both. Each got an identical set of eleven prompts. The question is simple. Does declaring endpoints as a wiring of small, named functions give an AI coding assistant a better index into the code? And does that make changes cheaper, more reliable, and more maintainable?

The short answer

The results support the hypothesis directionally. More usefully, they show exactly where it holds. OfficeFloor wins clearly on comprehension. That means listing endpoints and explaining a request flow. It also wins on cross-cutting changes that fan out across many endpoints. Authorisation, audit logging, and shared validation are examples. It draws or slightly loses on simple in-place logic fixes. The advantage washes out on deep data-layer changes, where most of the work lives below the endpoint.

It is not yet conclusive as a general "cheaper changes" claim. There were only three runs per prompt, and run-to-run variance was high. The two applications are not perfectly equal. And OfficeFloor had one recurring habit: it often stopped before writing tests. So a tighter second round is needed. But the strongest finding is architectural. In Spring, new behaviour kept getting bolted into existing controller and service methods. In OfficeFloor, it always landed as a new, single-purpose function.

The hypothesis

OfficeFloor (officefloor.net) provides a Spring Boot starter. It lets REST endpoints be declared in YAML as composed functions. These functions behave less like traditional service methods. They behave more like code blocks woven together by the configuration. The hypothesis is that this structure gives an AI a ready-made contextual index into the codebase. It is a map of what exists and how a request flows. That should make the code easier and cheaper to understand and change.

The setup

Both applications derive from the well-known Spring PetClinic REST reference application. They were deliberately made equivalent. Same domain, same persistence layers, same test suite. They differ only in how endpoints are expressed.

  • Spring. Endpoints are methods on @RestController classes. This is the stock PetClinic REST approach.
  • OfficeFloor. Each endpoint is a YAML file wiring together small Java functions. New behaviour is added by writing a function and wiring it into the pipeline.

A fork is available so you can try the experiment yourself. It is at github.com/officefloor/spring-petclinic-rest. The two variants live on branches spring-compare and officefloor-compare. Check out each branch. Run the prompts below against it. Then compare your own numbers.

The same prompt was run against each application. Every prompt was run three times per application. The hardest one was run four times. Each run captured cost, API duration, and the resulting diff. The agent was Claude (Opus-class) driving Claude Code.

How to read the cost numbers

Cost is dominated by cache-read tokens. That is effectively a proxy for how much code the agent had to pull into context. So it is a reasonable stand-in for "how hard was it to find and understand the relevant code." The API duration is the time spent in model inference. It is the cleanest measure of how much work the model did. Two caveats are worth keeping in mind throughout.

  • Different approaches swing the cost wildly. Sometimes the agent chose to add a versioned v2 endpoint instead of editing in place. That roughly doubled the cost. This happened independent of the framework.
  • "Cheap" sometimes means "unfinished." OfficeFloor frequently stopped after the functional change. It left tests unwritten until nudged. The averages below fold those follow-up prompts back in. That keeps the comparison like-for-like to a finished change.

Cost per prompt, at a glance

This is the average total cost in USD across all runs. It includes any follow-up prompts needed to reach a finished change with tests. Prompt 10 is averaged over four runs. All others are averaged over three. Lower is cheaper.

PromptCategorySpringOfficeFloorCheaper
1 · Count endpointscomprehension$0.35$0.25OfficeFloor
2 · 404 to 200 empty listin-place fix$0.50$0.76Spring
3 · Reject blank phonein-place fix$0.92$1.19Spring
4 · Visit date limitcross-cutting$1.03$1.28about even
5 · Duplicate pet namecross-cutting$2.07$1.91OfficeFloor
6 · New pets endpointnew endpoint$1.81$1.49OfficeFloor
7 · Paginate vetsdeep vertical$2.97$2.40OfficeFloor
8 · VET read accesscross-cutting$2.07$1.81OfficeFloor
9 · Audit deletescross-cutting$1.25$1.02OfficeFloor
10 · Soft-delete ownerdeep vertical$5.96$3.80OfficeFloor
11 · Explain flowcomprehension$0.81$0.40OfficeFloor

What the numbers say

Read the table together with the per-prompt detail below. Four categories emerge.

  • Comprehension (1, 11). Clear OfficeFloor win. It was roughly 30 to 50 percent cheaper. It was about twice as fast on API time. It was more consistent. And it pulled in far less code, with cache reads roughly half. This is the hypothesis's mechanism showing up directly. The YAML pipeline is the endpoint index. It is also the request flow. So the agent reads a map instead of reconstructing one.
  • Cross-cutting changes (5, 8, 9, and the coverage side of 4). OfficeFloor win. A change must apply to many endpoints here. Declarative wiring pays off. The authorisation change is a one-line edit across seven YAML files. Audit logging is five small functions wired into five DELETE pipelines.
  • Simple in-place logic fixes (2, 3). Spring win. The change lives inside a single method body. The extra function-plus-YAML indirection is overhead, not leverage. Spring was cheaper. It was also more reliably one-shot.
  • Deep vertical changes (7, 10). Roughly even. Most of the work is in shared repository, model, and schema layers. So the endpoint-layer advantage dilutes. OfficeFloor still edged ahead on cost. But both were expensive and high-variance.

The strongest finding: where new behaviour lands

A consistent structural difference appeared across the runs. In Spring, the agent tended to accrete new behaviour into existing methods. Validation went into savePet. Checks went into controller methods. Audit log() calls went inside each delete method. Method responsibilities grew beyond what their names promised.

In OfficeFloor, the composition model forced new behaviour into new, named functions. Examples include ValidateVisitDate, CheckNewPetName, and AuditPetDeletion. They were wired in via YAML. Existing functions stayed single-purpose. There was nowhere to quietly tuck extra logic.

This is the most compelling case for OfficeFloor's real payoff. That payoff is not the cost of change number 1. It is the cost of change number 20, after nineteen changes have accumulated. The single-shot cost figures cannot see that. Every prompt started from the same clean baseline.

The prompts and the data, one by one

1. How many REST endpoints does the application have?

How many REST endpoints does the application have?
StackRunCostAPILines ΔWhat it did
Spring1$0.4346s0/0Found 36 plus root redirect (also /oops), from OpenAPI YAML as authoritative source
2$0.3547s0/0Found 37 with /oops. Missed root redirect even when checking controllers
3$0.2843s0/0Same as run 2
OfficeFloor1$0.3418s0/0Found 37 including root redirect (no /oops), taken from YAML index
2$0.2118s0/0Same
3$0.2118s0/0Same

OfficeFloor win. It was cheaper. It was about 2.5 times faster on API time. It was perfectly consistent. It reads the YAML index directly. Note that the ground truth itself differs slightly. Spring surfaced /oops. OfficeFloor surfaced the root redirect. That muddies a pure correctness comparison.

2. Empty search result should be 200, not 404

GET /api/owners returns 404 when no owners match the lastName filter. That's wrong. Searching and finding nothing should return 200 with an empty list, the same as any other empty result set. Fix it.
StackRunCostAPILines ΔWhat it did
Spring1$0.481m3s+4/−5Removed the if statement and fixed the test
2$0.501m3s+4/−5Same
3$0.511m7s+4/−5Same
OfficeFloor1$0.3040s0/−4Removed the if but did not fix tests. Flagged that other list endpoints needed updating
1 · follow-up$0.551m22s+4/−6"run the tests" then fixed tests
2$0.491m8s+4/−6Removed the if and fixed tests in one shot
3$0.3335s0/−4Removed the if. Did not fix tests
3 · follow-up$0.621m19s+4/−6"run the tests" then fixed tests

Spring win. A one-line body change is Spring's home turf. It was consistent, one-shot, and cheaper. OfficeFloor needed a nudge to finish tests in two of three runs. It did notice sibling endpoints with the same bug.

3. Reject a new Owner with a blank telephone number

When a new Owner is created without a telephone number, don't leave it blank. Reject the request with a 400 and a clear validation message instead of silently saving a blank phone number.
StackRunCostAPILines ΔWhat it did
Spring1$0.882m14s+26Wrote two tests (blank and missing phone) to confirm existing behaviour
2$0.932m14s+26Same
3$0.952m37s+26Same
OfficeFloor1$1.534m28s+36/−3Wrote two tests to confirm. An expensive outlier run
2$1.042m50s+26Same
3$0.992m52s+26Same

Spring win. Both discovered the validation already existed. Both just added tests. So this mostly measures exploration cost. Spring was cheaper and steadier. OfficeFloor threw one pricey outlier.

4. Reject visits dated more than a year out

Vets shouldn't be able to book a visit more than one year in the future. Adding a visit dated beyond that should fail with a 400 and a message explaining why.
StackRunCostAPILines ΔWhat it did
Spring1$1.032m22s+76Validation annotation class, updated openapi.yml, test for POST /visits (inferred PUT)
2$0.891m56s+83Validation class, openapi, unit test, plus 2 Spring MVC integration tests
3$1.163m6s+71Exception in ClinicServiceImpl, advice covering all endpoints, 2 integration tests
OfficeFloor1$0.752m3s+78Exception plus reusable function wired into 3 endpoints (POST /visits, POST owners/.../visits, PUT /visits/{id})
1 · follow-up$1.022m33s+121"add test for 400 response" then 3 tests
2$1.122m30s+97Like run 1 but missed PUT /visits/{visitId}
3$0.962m32s+73Exception plus function on the two POSTs (missed PUT). No tests

Slight OfficeFloor edge, mixed. OfficeFloor reused one ValidateVisitDate function across multiple endpoints via YAML. That is broader coverage for less code. But both stacks were inconsistent. They missed the PUT endpoint at times. And they varied on writing tests unprompted.

5. No two pets with the same name for one owner

An owner shouldn't be able to register two pets with the exact same name. When adding or renaming a pet, reject it with a 400 if that owner already has a pet with that name.
StackRunCostAPILines ΔWhat it did
Spring1$1.924m30s+163/−4Check added inside ClinicServiceImpl.savePet, plus exception, handler, and tests
2$2.154m13s+139Logic in the controllers (incl. updatePet), plus exception, handler, and tests
3$2.144m48s+165Same as run 2
OfficeFloor1$2.034m59s+230Exception, handler, focused check functions, YAML wiring into 3 pipelines, and tests
2$2.104m52s+206Same
3$1.614m59s+122Same but no tests

OfficeFloor win, and a scope-creep exemplar. The cost was comparable. But the shapes differ sharply. Spring folded the check into savePet or the controller methods. OfficeFloor created discrete single-purpose functions, such as CheckNewPetName and CheckRenamedPetName. Then it wired them in.

6. Add a "list an owner's pets" endpoint

Add an endpoint to list every pet belonging to a given owner, GET /api/owners/{ownerId}/pets, returning 404 if the owner doesn't exist and an empty array if they have no pets.
StackRunCostAPILines ΔWhat it did
Spring1$1.653m53s+92openapi.yml, controller method, and tests, in one shot
2$1.764m8s+92Same
3$2.014m11s+89Same
OfficeFloor1$0.541m0s+31Reused LoadPet, new function, and YAML endpoint. No tests
1 · follow-up$0.881m35s+68"write a test for this endpoint"
2$0.4756s+29Same. No tests
2 · follow-up$0.801m28s+65"write a test for this endpoint"
3$0.7256s+30Same. No tests
3 · follow-up$1.061m29s+65"write a test for this endpoint"

OfficeFloor win on effort, with a caveat. The base implementation was strikingly cheap. It reused an existing LoadPet function. That is a clean composition win. But it omitted tests in all three runs. Fold the test prompt back in and the total is comparable to Spring. Spring delivered tests first time.

7. Paginate the vet list

The vet list is going to get long. Add page and size query parameters to GET /api/vets so callers can page through results, same as they already can for owners.
StackRunCostAPILines ΔWhat it did
Spring1$3.375m33s+286/−3New v2 endpoint. Repository, service, mapper, openapi, and tests
2$2.535m46s+282/−1Same (v2)
3$3.016m41s+131/−4Updated the existing endpoint in place
OfficeFloor1$2.205m48s+188/−21Endpoint function, repository methods, mapper, openapi, and tests (in place)
2$2.986m26s+276/−3New v2 endpoint
3$2.025m7s+180/−21Updated in place

Slight OfficeFloor edge. Effectively a draw. Most of the work is in shared repository, service, and mapper layers. So the endpoint abstraction matters little. Both stacks split between "add a v2" and "edit in place." That decision drove cost more than the framework did.

8. Give the VET role read access

Vets should be able to view owner and pet records. Right now only admins can. Give the VET role read access (GET requests) to owners and pets, but writes should stay admin-only.
StackRunCostAPILines ΔWhat it did
Spring1$1.513m35s+11/−11Switched to hasAnyRole across 7 endpoints, updated tests, and also spotted security was disabled
2$1.904m49s+69/−7Same, plus added tests
3$1.072m47s+7/−7Annotation change only
3 · follow-up$1.724m4s+124/−11"fix tests and add tests"
OfficeFloor1$0.4654s0/0Edited role in 7 YAML files (hasAnyRole). No tests
1 · follow-up$1.232m28s+120/−4"fix tests and add tests"
2$0.4154s0/0Same. No tests
2 · follow-up$1.613m23s+155/−4"fix tests and add tests"
3$0.4146s0/0Same. No tests
3 · follow-up$1.322m31s+137/−4"fix tests and add tests"

OfficeFloor win on the change itself. The authorisation edit is purely declarative. It was seven one-line YAML changes for under $0.50. Spring had to edit annotations across multiple controllers. But OfficeFloor wrote no tests in any run without a nudge. This is the clearest instance of its test-discipline gap.

9. Audit trail on deletes

We need an audit trail. Every time any resource (owner, pet, vet, pet type, specialty) is deleted, log the authenticated user, the resource type, and its id to a dedicated logger named AUDIT.
StackRunCostAPILines ΔWhat it did
Spring1$1.153m1s+31Log statements added inside each delete method. No tests
2$1.363m46s+68/−1AuditLogger dependency plus calls in each delete method. No tests
3$1.233m28s+25Same, no wrapper. No tests
OfficeFloor1$0.912m30s+124Dedicated audit function per resource plus shared AuditLog, wired into 5 DELETE YAMLs
2$1.173m21s+121Same
3$0.982m47s+118Same

OfficeFloor win. It was cheaper and more consistent. It was also more modular. It used separate audit functions wired declaratively. Spring threaded log lines into the existing delete methods. Neither wrote tests.

10. Soft-delete owners

Deleting an owner shouldn't remove their record. It should set a deleted flag instead. Deleted owners must stop showing up in the owner list and in individual lookups (404), but DELETE /api/owners/{id} should still respond 204 as before.
StackRunCostAPILines ΔWhat it did
Spring1$3.8419m56s+52/−3Repository-level soft delete plus entity annotations
1 · follow-up$4.6421m36s+61/−11"also apply to the jdbc profile"
2$4.2311m56s+73/−34Soft delete in ClinicServiceImpl rather than repository
3$6.2316m37s+85/−51Repository level
4$2.195m53s+22/−3Minimal via repositories plus annotations. No tests
4 · follow-up$2.706m54s+31/−11"convert the jdbc profile too"
OfficeFloor1$5.3911m58s+95/−38deleted column, entity, repo checks. Delete function sets flag. Tests. Also fixed owner-pets lookup
2$4.218m53s+73/−18Same, using an entity annotation for the soft delete
3$2.986m55s+61/−35Same
4$2.636m7s+76/−29Same

Slight OfficeFloor edge on the hardest task. This touches the model, three repository implementations, schema files for several databases, and tests. So the endpoint layer barely matters. Both were expensive and high-variance. But OfficeFloor was somewhat more consistent. It did all repositories in one pass. Spring twice needed a "do the jdbc profile too" nudge. OfficeFloor also once caught the knock-on owner-pets lookup.

11. Explain the PUT-owner request flow (no code)

Before I ask you to change anything: walk me through exactly what happens, in order, when a PUT request comes in to update an existing owner, from the HTTP request landing to the response going out. Don't write any code yet.
StackRunCostAPILines ΔWhat it did
Spring1$0.581m11s0/0Full report. Identified an incorrect 204 that should be 200
2$1.073m1s0/0Same
3$0.781m40s0/0Same
OfficeFloor1$0.4156s0/0Full report. Identified the same 204-should-be-200 bug
2$0.4052s0/0Same
3$0.3955s0/0Same

OfficeFloor win. It cost about half as much. It was far more consistent. Both stacks independently spotted the same latent bug. But OfficeFloor read the flow off the YAML pipeline. Spring reconstructed it from controller, service, and mapper code.

Conclusion

The experiment is genuinely encouraging. It supports the idea that a composed-function architecture gives an AI a better index into a codebase. The comprehension and cross-cutting results are exactly what the hypothesis predicts. The token accounting shows the mechanism at work. But the clearest signal is not in any single cost figure. It is structural. OfficeFloor's model keeps functions small and single-purpose by construction. The conventional controllers steadily accumulated responsibilities beyond their names. If that pattern holds up under a longitudinal test, then it is the real case for the approach. Not the per-change cost.

Try it yourself. The fork is at github.com/officefloor/spring-petclinic-rest. Branch spring-compare is the conventional @RestController build. Branch officefloor-compare is the YAML composed-function build. Run the eleven prompts above against each. Then see how your numbers compare.