Wednesday, 23 September 2026

What you must understand to change one rule

Part of a series on how software architecture shapes AI driven code degradation. This post explains a group of measurements on their own. Each metric gets its definition, its figure, and its numbers.

Complexity reachable from a typical handling step

node_cc_median  ·  ↓ lower is better  ·  harness call-graph walk from the declared wiring nodes

Complexity reachable from a typical handling step

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

Pick one step in the request's handling. Follow every method it calls, transitively, and add up the complexity. That total is what a developer must understand to change that one step. This metric is the median of that total across all the steps. It is the closest thing on this page to the real question, which is what it costs to change one rule.

How it is calculated
node_cc_median = medianr ∈ nodes cc(closure(r))

Where. Nodes are the steps the request passes through. They come from the arm's own declared wiring, which is the YAML file named by node_roots.wiring_file. An arm that declares no wiring contributes exactly one node, its entry_handler. closure(r) is the set of methods reachable from node r by following Java call edges transitively, computed by breadth-first search over a call graph the harness builds from lizard's function list plus name resolution. cc(S) = Σk ∈ S CC(k) for a set of methods S.

In this harness. metrics.node_closure_stats. The call index is built once per checkpoint and shared with the indirection and propagation metrics, because resolving the graph is the expensive part.

How to read it. This is the concentration statistic to lead with. It is relocation-proof: work pushed into a helper still lands in that helper's caller's closure, so moving code downstream does not improve it. It is the metric that survived the condition where the agent was told the scoring formula.

Careful. Code the framework dispatches is not code the handler calls. A rule moved into a @RestControllerAdvice, an @Aspect, a servlet Filter, a @PrePersist entity listener or a ConstraintValidator leaves this walk entirely, because the container invokes it and there is no Java call edge to follow. Check the framework-dispatch counter in the validity group before trusting this for a given condition.

Complexity reachable from the worst handling step

node_cc_max  ·  ↓ lower is better  ·  harness call-graph walk

Complexity reachable from the worst handling step

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

The same closure complexity, for whichever step is worst. The median says what a typical change costs. This says what the worst change costs, which is often what a team actually remembers.

How it is calculated
node_cc_max = maxr ∈ nodes cc(closure(r))

Where. Nodes are the steps the request passes through. They come from the arm's own declared wiring, which is the YAML file named by node_roots.wiring_file. An arm that declares no wiring contributes exactly one node, its entry_handler. closure(r) is the set of methods reachable from node r by following Java call edges transitively, computed by breadth-first search over a call graph the harness builds from lizard's function list plus name resolution. cc(S) = Σk ∈ S CC(k) for a set of methods S. The harness also records the mean and the 90th percentile of the same distribution, as node_cc_mean and node_cc_p90.

In this harness. Same single pass as the median.

How to read it. A distributed architecture is allowed a high median and a low maximum, because it has many small steps. It is in trouble if its maximum approaches the concentrated arm's, because that means one of its steps has become the god method it was supposed to avoid.

Careful. Code the framework dispatches is not code the handler calls. A rule moved into a @RestControllerAdvice, an @Aspect, a servlet Filter, a @PrePersist entity listener or a ConstraintValidator leaves this walk entirely, because the container invokes it and there is no Java call edge to follow. Check the framework-dispatch counter in the validity group before trusting this for a given condition.

Complexity of the entire handling path

node_path_cc  ·  ↓ lower is better  ·  harness call-graph walk, unioned over all nodes

Complexity of the entire handling path

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

Everything reachable from any step of the request path, counted once. This is the honest total: what the whole feature costs to understand. Taking the union rather than the sum matters, because a shared helper reachable from six steps is one thing to learn, not six.

How it is calculated
node_path_cc = cc( ⋃r ∈ nodes closure(r) )

Where. Nodes are the steps the request passes through. They come from the arm's own declared wiring, which is the YAML file named by node_roots.wiring_file. An arm that declares no wiring contributes exactly one node, its entry_handler. closure(r) is the set of methods reachable from node r by following Java call edges transitively, computed by breadth-first search over a call graph the harness builds from lizard's function list plus name resolution. cc(S) = Σk ∈ S CC(k) for a set of methods S. The union is over method identities, so a helper reached from several nodes is counted exactly once. The harness also records the size of that union as node_path_methods.

In this harness. Same single pass. The set union is taken before summing complexity, not after.

How to read it. This is the answer to “you just moved it downstream”. It must be published beside the front-door and handler-class metrics. It is also where the two architectures have historically come out closest. In one control run it read 229 against 202. That is the same total work, arranged differently. It is Tesler's conservation showing up in the place where it is hardest to argue with.

Careful. Code the framework dispatches is not code the handler calls. A rule moved into a @RestControllerAdvice, an @Aspect, a servlet Filter, a @PrePersist entity listener or a ConstraintValidator leaves this walk entirely, because the container invokes it and there is no Java call edge to follow. Check the framework-dispatch counter in the validity group before trusting this for a given condition. A chain whose rules moved into advice classes can report a path complexity in single digits while implementing all sixty rules. A number that low is a detector of the escape, not a result.

Number of handling steps

node_count  ·  · descriptive  ·  harness, from the architecture's declared wiring

Number of handling steps

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

How many distinct steps the request passes through. This is the mechanism, not a finding. It is also the correct denominator when reading the median closure complexity, because a rising median across a rising node count is a different story from a rising median at a fixed one.

How it is calculated
node_count = | nodes |

Where. Nodes declared in the arm's wiring file, or 1 for an architecture that declares none. The asymmetry is real and is the point: one arm has a single node by design.

In this harness. metrics._node_roots parses the wiring file and resolves each declared step to a method in the call index. A step it cannot resolve is dropped, so this is a floor rather than a declaration count.

How to read it. Read it beside the median. It is also the honest companion to the indirection metrics, whose depth figure understates a pipeline exactly because all its steps sit at depth zero.

How much of the path belongs to exactly one step

node_exclusive_share  ·  ↑ higher is better  ·  harness call-graph walk

How much of the path belongs to exactly one step

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

Of all the complexity reachable from the handling path, what fraction is reachable from only one step. High means each step owns its own logic. Low means the steps are thin wrappers over a shared blob. This is the metric that would expose a fake decomposition, because twenty wired steps that all call the same helper would show a high step count and a low exclusive share.

How it is calculated
reach(k) = | { r : k ∈ closure(r) } |
node_exclusive_share = Σr cc({ k ∈ closure(r) : reach(k) = 1 })  /  Σr cc(closure(r))

Where. Nodes are the steps the request passes through. They come from the arm's own declared wiring, which is the YAML file named by node_roots.wiring_file. An arm that declares no wiring contributes exactly one node, its entry_handler. closure(r) is the set of methods reachable from node r by following Java call edges transitively, computed by breadth-first search over a call graph the harness builds from lizard's function list plus name resolution. cc(S) = Σk ∈ S CC(k) for a set of methods S. reach(k) counts how many nodes can reach method k. Note that the denominator is the sum over nodes, not the union, so a method shared by six nodes is counted six times below the line and zero times above it. That is what makes sharing expensive in this ratio.

In this harness. Same single pass. Blank for an arm with one node, where it would be trivially 1.0. That blank is deliberate: in an arm-versus-arm table a trivial 1.0 would read as perfect cohesion when it actually means there are no separable rules to share between.

How to read it. This is the cohesion test for a pipeline architecture. It is the number to ask for when someone claims a decomposition is only cosmetic.

Methods reachable from a typical handling step

node_methods_median  ·  ↓ lower is better  ·  harness call-graph walk

Methods reachable from a typical handling step

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

The same closure, counted in methods rather than in complexity. It is how many distinct methods you would have to read. Because it weights every method equally it is immune to any argument about how complexity should be scored.

How it is calculated
node_methods_median = medianr ∈ nodes | closure(r) |

Where. Nodes are the steps the request passes through. They come from the arm's own declared wiring, which is the YAML file named by node_roots.wiring_file. An arm that declares no wiring contributes exactly one node, its entry_handler. closure(r) is the set of methods reachable from node r by following Java call edges transitively, computed by breadth-first search over a call graph the harness builds from lizard's function list plus name resolution. cc(S) = Σk ∈ S CC(k) for a set of methods S.

In this harness. Same single pass as the complexity median.

How to read it. A count-based confirmation of the closure result. If it agrees with the complexity median, the finding does not depend on McCabe weights.

Careful. Code the framework dispatches is not code the handler calls. A rule moved into a @RestControllerAdvice, an @Aspect, a servlet Filter, a @PrePersist entity listener or a ConstraintValidator leaves this walk entirely, because the container invokes it and there is no Java call edge to follow. Check the framework-dispatch counter in the validity group before trusting this for a given condition.

No comments:

Post a Comment