Topic 03

Quiet Behaviour Changes

Failure Modes

A refactor is supposed to change how code reads and nothing about what it does. An agent that tidies code on the way to a task often changes a rule as well, a rounding mode, a boundary or a default, and no test notices, because no test covered that rule. The change is quiet because nothing checks it, not because it is clever.

The refund diff carried two of these. Neither was asked for, neither failed a test, and each one changes what a member is paid. They are the reason Chapter 6 asked you to write a before-and-after rule for every changed expression.

The Shape

The line nobody asked to change sits inside code everyone agreed could be touched. The task named the refund function, so the refund function was in scope, and the agent rewrote more of it than the task needed, "for readability". Agents reorganise surrounding code freely unless told not to, and every rewritten line is a chance to swap the project's deliberate choice for the common idiom.

Nothing about the result looks wrong. The new line is shorter, more familiar and often what a style guide would suggest. The only way to see the change is to state what the old line did and what the new one does, as rules, and compare them.

In the refund diff both quiet changes sit in the refunds file, inside the one hunk, about 30 lines long, that also wrapped the refund decision in the new policy check. A reviewer who reads that hunk for its purpose, "now uses the policy", reads past both. That is the general pattern: a quiet change rides inside a larger change that has an obvious purpose, and the purpose is what the eye follows.

The Rounding Case

Cragmoor sells three-class packs. When a member cancels one class of a pack, the refund is a third of what they paid, and the finance team's rule is that the amount rounds down to the cent. For a pack bought for $50.00, the numbers this page uses, one class refunds $16.66. The refund diff rewrote the amount function, and the new version rounds to the nearest cent instead.

git diff refunds.py (one hunk of the refund diff, trimmed)
 def class_share_cents(booking: Booking) -> int:
     """One class's share of what the member paid."""
-    return booking.paid_cents // booking.classes
+    return round(booking.paid_cents / booking.classes)
One class of a $50.00 three-class pack, before and after
16.66
16.67
git diff refunds.ts (one hunk of the refund diff, trimmed)
 export function classShareCents(booking: Booking): number {
-  return Math.floor(booking.paidCents / booking.classes);
+  return Math.round(booking.paidCents / booking.classes);
One class of a $50.00 three-class pack, before and after
16.66
16.67
git diff Refunds.java (one hunk of the refund diff, trimmed)
   public static long classShareCents(Booking booking) {
-    return booking.paidCents() / booking.classes();
+    return Math.round((double) booking.paidCents() / booking.classes());
One class of a $50.00 three-class pack, before and after
16.66
16.67

One cent per pack refund, in the member's favour, on every pack refund from the deploy onwards. No test covered packs, so the suite gave the same result before and after. The change is one line, it sits inside a hunk of about 30 lines that rewrote most of the refunds file, and its new form is exactly what most engineers would write if asked to divide money by three.

The Boundary Case

The second quiet change is worse. The old rule refunded a cancellation made at least 24 hours before the class. The task changed 24 to 12. Inside the block the agent re-indented to wrap in its new policy check, "at least" also became "more than". In code that is one character in a comparison.

Nobody asked for it and nothing failed, because no test cancelled at exactly the boundary. A member who cancels exactly 12 hours before the class is now refused, against Ines's ruling that "up to 12 hours" includes the twelfth hour. Chapter 8 adds the test that would have failed.

The refund diff's two quiet changes, written as rules a member could notice
Pack refundone class of a $50.00 pack
Before: $16.66, rounded down by the finance rule. After: $16.67, rounded to the nearest cent. One cent per refund, every pack refund, no failing test.
Cancelling exactly on the line12 hours before the class
Intended: refunded, because the rule says "up to 12 hours". The agent's code: refused, because "at least" became "more than". One character, no failing test.

Why It Happens

The agent writes the most common way to say a thing. The most common rounding in public code is to the nearest, and the most common comparison against a limit is strict. A codebase's deliberate exceptions are, by definition, uncommon, so to a model they look like mistakes, and a model asked to tidy code "corrects" them.

The same pull towards the common form shows up everywhere a codebase made a choice. A range that includes its end becomes one that excludes it. A sort that kept equal items in their original order becomes one that does not promise to. A default of "no refund" becomes a default of "full refund", because the agent read the parameter's name and guessed. Trimming whitespace from a member's name, treating an empty string as missing, comparing names without regard to case: each is a reasonable idiom, and each is a rule change if the code had chosen otherwise.

Nothing in the code told the agent otherwise. The round-down had no comment and no test; the inclusive comparison had no test at its boundary. A line that carries a business decision looks exactly like a line that carries an accident unless something says which it is.

Catching It

Keep refactors and behaviour changes in separate commits, the rule Chapter 9 enforces with one task per branch. A pure refactor can then be checked against tests that pin current behaviour, the method Chapter 11 builds, and declared "no behaviour change" with evidence. A refactor mixed into a behaviour change can only be declared tidy.

In review, write a before-and-after rule for every rewritten expression. "Before: rounds down. After: rounds to nearest." takes ten seconds and shows what the syntax hides. And give every deliberate rule a test or a comment that says why, so the next author, human or agent, knows it is deliberate.

A comment that only restates the code does not help: "rounds down" over a line that rounds down tells the next reader nothing they could not see. The comment that protects the rule says where it came from: "Finance rule: pack refunds round down to the cent, never in the member's favour." A test does the same job and also fails when the rule is broken, which a comment cannot. The best answer is both, and the test is the one that counts.

What It Costs

A quiet change is found months later by a member, an accountant or an auditor, when nobody remembers the diff. The cost is not the cent. It is the investigation into why pack refunds stopped reconciling, the correction of every refund since the deploy, and the trust lost by a finance team that had written its rule down and watched it disappear in a change about something else.

At Cragmoor's volume the money is small. With about 3,000 pack refunds a year, the figure this page uses, one cent each is $30.00. Nobody would approve a project to save $30.00, and nobody would approve a change that silently overrides a finance rule either. The number that matters is not the cent but the count of rules the codebase holds without a test, because each one is a quiet change waiting for the next tidy-up.

Misconceptions
  • "A refactor cannot change behaviour if the tests still pass." It cannot change tested behaviour. Nothing tested packs, and nothing tested a cancellation at exactly 12 hours.
  • "The agent changed only what I asked." It changed what it rewrote. Everything inside a rewritten block is part of the change, whether or not the task named it.
  • "The language's standard rounding is safer than custom code." The custom code was the rule. Replacing it with the standard idiom changed the rule, however standard the idiom.
  • "One cent does not matter." Across a year of pack refunds it is an accounting discrepancy someone has to explain, and the same mechanism flipped a boundary that refuses members their refunds.
  • "Review will notice a changed rounding call." Inside a hunk of about 30 lines a reviewer reads the intent, "tidier", rather than each expression. The before-and-after rule is what surfaces it.
Why It Matters
  • Forbid incidental refactoring in the task and in the project instructions file: "change only what the task needs; propose refactors separately".
  • Split refactors and behaviour changes into separate commits. A pure refactor can then be verified as one.
  • Mark every deliberate rule with a test or a comment that gives the reason. An undocumented exception reads as a bug to the next author.
  • Write a before-and-after rule for every rewritten expression in a diff. The rule shows what the syntax hides.

Knowledge Check

A member cancels one class of a three-class pack bought for $50.00. What does the refund come to before and after the agent's rewrite?

  • $16.66 before, and $16.66 after, since the division is the same
  • $16.67 before, rounded to nearest; $16.66 after, rounded down
  • $16.66 before, rounded down; $16.67 after, rounded to nearest
  • $16.666 before; $16.67 after, rounded for display only

Why does an agent asked to tidy a function replace a deliberate round-down with ordinary rounding?

  • The common idiom is likelier, and nothing marked the old one deliberate
  • Ordinary rounding is more accurate, and agents prefer the most accurate code
  • It cannot see the function's body unless it is told to open the file
  • Its instructions told it to standardise every rounding call it finds in a file

Which commit structure lets a reviewer verify that the tidying part of an agent's change altered no behaviour?

  • One commit holding the rule change and the tidying, with its tests
  • The refactor alone in its own commit, checked against pinned tests
  • Several small commits squashed into one before the review begins
  • A single commit with a message saying "no behaviour change" in it

What makes a behaviour change quiet, in the sense this page uses?

  • The change is subtle enough that a careful reviewer cannot see it
  • The change sits in a file that the task did not mention at all
  • The agent leaves the change out of the summary it writes at the end
  • No test or other check covers the rule that the line encodes

Inside the hunk of about 30 lines that rewrote the refunds file, which review step is most likely to expose "at least" becoming "more than"?

  • Reading the agent's summary of what the rewrite set out to do
  • Writing the before-and-after rule for every changed comparison
  • Running the existing test suite again and checking that it is green
  • Counting how many lines of the function were re-indented by it

You got correct