Pin in-place mutation before any extract. Return-only goldens hide alias bugs. Freeze four ledger fields first. Then lift one pure helper.
This workflow targets messy Python services. It does not rewrite the module. It records mutation, then changes one seam.
Why return-only pins fail
Many helpers mutate a dict argument. They also return that same object. Callers keep both names alive. A later write hits every alias.
Characterization tests often assert equality only. Equality cannot see shared identity. A later "pure" extract copies the dict. Hidden aliases then stop updating. Production breaks without a red test.
The failure is mechanical, not stylistic. Object identity is a behavior. Mutation diffs are also behavior. Both belong in the golden file.
Four fields in the ledger
Record a snapshot before the call. Record the return payload after the call. Record whether input is return. Record a key-level mutation diff.
Identity true means a shared object. Diff nonempty means real mutation. Both facts must stay stable. Only then consider an extract.
Empty diffs mean a different seam. True identity plus a copy extract is unsafe. False identity plus a matching tree is already safer. Read those flags before editing.
Artifact: a mutation ledger
The harness below is a proposed example. It is not from a production run. It uses copy.deepcopy and JSON. Adjust types to your helper.
# tools/mutation_ledger.py
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import Any, Callable
def _jsonable(value: Any) -> Any:
return json.loads(json.dumps(value, sort_keys=True, default=str))
def _flat_diff(before: dict, after: dict) -> dict[str, dict]:
keys = set(before) | set(after)
out: dict[str, dict] = {}
for key in sorted(keys):
if before.get(key) != after.get(key):
out[str(key)] = {
"before": _jsonable(before.get(key, "<missing>")),
"after": _jsonable(after.get(key, "<missing>")),
}
return out
def capture(fn: Callable, payload: dict) -> dict:
snapshot = copy.deepcopy(payload)
inbound = copy.deepcopy(payload)
result = fn(inbound)
identity = inbound is result
return {
"snapshot": _jsonable(snapshot),
"returned": _jsonable(result),
"identity": identity,
"diff": _flat_diff(snapshot, inbound),
}
def write_golden(path: Path, rows: list[dict]) -> None:
path.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
# tests/test_mutation_ledger.py
import json
from pathlib import Path
from tools.mutation_ledger import capture
# Proposed stand-in for a messy billing helper.
def apply_discount(cart: dict) -> dict:
rate = 0.1 if cart.get("coupon") == "SAVE10" else 0.0
cart["discount"] = round(cart["subtotal"] * rate, 2)
cart["total"] = round(cart["subtotal"] - cart["discount"], 2)
return cart
FIXTURES = [
{"subtotal": 40.0, "coupon": None},
{"subtotal": 40.0, "coupon": "SAVE10"},
{"subtotal": 0.01, "coupon": "SAVE10"},
]
GOLDEN = Path("goldens/apply_discount.json")
def test_ledger_matches_golden():
rows = [capture(apply_discount, row) for row in FIXTURES]
assert GOLDEN.exists(), "commit goldens before any extract"
expected = json.loads(GOLDEN.read_text())
assert rows == expected
Run the capture once on a clean tree. Save the JSON goldens. Commit them before any extract. Later diffs then mean behavior drift.
A first golden for the coupon case looks like this. Identity stays true. The diff lists discount and total only.
{
"snapshot": {"coupon": "SAVE10", "subtotal": 40.0},
"returned": {"coupon": "SAVE10", "discount": 4.0, "subtotal": 40.0, "total": 36.0},
"identity": true,
"diff": {
"discount": {"before": "<missing>", "after": 4.0},
"total": {"before": "<missing>", "after": 36.0}
}
}
Step 1: Pick a mutating helper
Choose a function with a dict or list argument. Prefer a helper with several callers. Skip constructors and tiny getters. One helper per change is enough.
Ignore helpers that only read fields. Ignore helpers that already copy. The ledger will tell you if you guessed wrong. Trust the identity flag over naming.
Step 2: Wrap without editing behavior
Keep the original function name public. Add capture around the live helper. Do not alter return values yet. The wrap is instrumentation only.
Leave logging, I/O, and imports untouched. Those are other pins. Mixing pins hides the mutation signal. Isolate this seam on purpose.
Step 3: Replay three real payloads
Use two typical carts. Add one ugly edge cart. Label synthetic fixtures as synthetic. Prefer structures copied from logs.
Do not generate payloads from model vibes. Three honest fixtures beat fifty guessed ones. Cover the zero-money path once. Cover the missing-key path once if it exists.
Step 4: Freeze the four fields
Write goldens as JSON files. Assert snapshot, return, identity, and diff. Fail the test if any field drifts. Keep assertion messages short.
mkdir -p goldens
python - <<'PY'
from pathlib import Path
from tests.test_mutation_ledger import FIXTURES, apply_discount
from tools.mutation_ledger import capture, write_golden
write_golden(Path("goldens/apply_discount.json"),
[capture(apply_discount, row) for row in FIXTURES])
PY
python -m pytest tests/test_mutation_ledger.py -q
Re-run after every edit. Wrapper goldens must stay byte-stable. New files belong only to the pure path.
Step 5: Extract one pure function
Add a new helper that copies first. Apply the same field edits to the copy. Return the new object only. Leave the old wrapper in place.
def apply_discount_pure(cart: dict) -> dict:
out = copy.deepcopy(cart)
rate = 0.1 if out.get("coupon") == "SAVE10" else 0.0
out["discount"] = round(out["subtotal"] * rate, 2)
out["total"] = round(out["subtotal"] - out["discount"], 2)
return out
def apply_discount(cart: dict) -> dict:
updated = apply_discount_pure(cart)
cart.clear()
cart.update(updated)
return cart
Callers that rely on mutation still work. New callers can take the pure path. That is the smallest safe change. Stop after this extract.
Step 6: Prove identity flipped on the pure path
Call the pure helper in a new test. Identity must be false there. The inbound mutation diff must be empty. The return payload must match the golden return.
def test_pure_path_breaks_identity():
payload = {"subtotal": 40.0, "coupon": "SAVE10"}
inbound = copy.deepcopy(payload)
result = apply_discount_pure(inbound)
assert inbound is not result
assert inbound == payload
assert result["total"] == 36.0
If identity stays true, the extract failed. If the input still changes, copies are missing. Fix the pure helper only. Do not touch other functions.
Decision table
| identity | diff empty | next action |
|---|---|---|
| true | no | pin, then copy-on-write extract |
| true | yes | no mutation seam; pick another helper |
| false | no | already copies; pin both trees |
| false | yes | likely pure; extract is optional |
Read the table before you extract. Do not extract on empty diffs. Do not claim purity when identity is true. Do not merge rows across helpers.
Treat a changed key set as a red flag. New keys mean extra writes. Missing keys mean dropped writes. Either case blocks the extract.
Commands after the extract
python -m pytest tests/test_mutation_ledger.py tests/test_pure_path.py -q
diff -u goldens/apply_discount.json <(python -c 'import json,pathlib; from tests.test_mutation_ledger import FIXTURES, apply_discount; from tools.mutation_ledger import capture; print(json.dumps([capture(apply_discount, r) for r in FIXTURES], indent=2, sort_keys=True))')
git diff --stat -- tests/ tools/ goldens/
The wrapper ledger must match the committed golden. The pure-path test is extra coverage. Diff stat should list one helper and tests. A wider stat means the change grew.
Remote run when the tree is dirty
Local worktrees often mix debug prints. Ledger files then pick up noise. Run the harness on a clean machine. Keep goldens off the messy branch.
MonkeyCode provides free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Use the free server to execute the ledger script. Store JSON goldens as artifacts there. Bring back only the committed files. Skip extra refactors the model suggests.
A free model can propose fixture names. Feed it ledger JSON only. Do not paste the full module. Do not accept an extract plan from it.
Human review of identity flags is required. Models miss aliasing more than equality. The ledger exists for that gap. Availability of free access can change.
Limitations
deepcopy fails on sockets, locks, and some C objects. Custom classes need a manual snapshot. Key order can churn the diff. Normalize dicts before compare.
The ledger ignores stdout and files. It ignores network and clocks. Combine other pins if those matter. This article covers mutation only.
The sample assumes CPython and pytest. It assumes JSON-serializable carts. Binary blobs need another encoder. Do not force JSON on sockets.
Nested list identity is not expanded here. Shared inner objects can still alias. Add an id() map if nests mutate. Stop if that map exceeds one helper.
Who should skip this
Skip this if helpers are already pure. Skip this if tests already check identity. Skip this for a one-line bugfix. Skip this if deepcopy cannot run.
Do not use this to justify a large rewrite. Do not extract three helpers at once. Do not rename while extracting. One pure function is the stop line.
If the helper must mutate for API reasons, keep the wrapper. Publish the pure function beside it. Move callers later, one at a time. That sequence stays reviewable.
The method is a pin, not a cleanup campaign. Green identity flags are the exit. Extra style edits are out of scope. Leave formatting for another branch.
Top comments (0)