Case study · timefold-solver

Separating a 1,596-file artifact into nine modules.

timefold-solver-core was a single artifact of 1,596 source files in which most types depended on most others. It is now nine modules with a build order the compiler enforces. Eleven consumers compile against 19% of the codebase where they used to need 45%. The test suite reads identically to a control build held throughout.

01

The project setup.

Four things take part in a run: the source, the CodeLaser engine, the agent that drives it, and the project’s own build. Each feeds the next.

your repository 1 596 source files in one artifact CodeLaser one model of every type and reference the source files, rewritten by an operation, not by hand Maven — compile, then the tests Claude puts questions to the model receives counts and ranked, priced options chooses the next step issues it as one operation records expected vs measured parsed once, at the start measurements, ranked options a question, then an operation the operation is applied the model is rebuilt from the new source pass or fail, and the counts
The Timefold source
A fresh clone of timefold-solver-core at upstream commit 9adba5acf5, on its own branch. The Timefold repository itself was never written to.
The CodeLaser engine
Reads the whole project once and builds a code model of it — every type, every member, every reference from one to another. It answers questions from that model, and it changes code through operations rather than by editing text.
Claude Code
Drives the work. It puts questions to the code model, receives measurements and ranked options back, chooses the next step from them, and issues it as one operation. It records what it expected each step to produce, and then what was actually measured.
The Maven build
Timefold’s own build, unchanged. After each step it compiles the project and runs the whole test suite.

Nothing is decided from a description of the code. Every question is answered from the code model as it stands at that moment, and every result is measured again after the change has landed.

The work is submitted as numbered runs, and every script and every run is kept. Runs are numbered as they are submitted, so a dropped one would leave a gap:

ClaimStatedFound on disk
distinct scripts276276 .py files
runs submitted369369 archived artefacts, numbered 1–369
gaps in the run sequencenone
test suites in the baseline344344 entries

A second check: the per-day effort figures sum to the whole-run totals. Requests match exactly; the token sums land within 0.13%, which is what quoting to three significant figures costs.

02

Nothing could be separated until a 978-type cycle came apart.

The artifact’s largest mutually-dependent group spanned 978 types. A module boundary cannot be drawn through a group like that: every candidate cut leaves edges running both ways, so the compiler cannot enforce an order. Breaking it was the precondition for everything else, and it took three phases.

The first phase changed nothing. Two scenarios were derived and priced read-only, to find out what construction-by-name was costing before any of it was touched.

ScenarioTypes no longer neededTypes freedLargest cycle after
A — the nameless-able concrete types49185766
B — A, plus the score-director family and the solver factory54193764
B, plus one further factory55340529

Thirteen predictions were made across that phase and thirteen were exact. How the scenarios were defined matters more than the hit rate: scenario A is derived, not listed — the members of the cycle, filtered by area and by name, minus a 21-entry set of declared contracts. The classifier was ported character for character rather than described again, because a paraphrase measures a slightly different set and produces a plausible near-miss.

Then the container, in two slices

Construction by name was replaced with container-managed injection in two passes, followed by a step that straightened the remaining wrong-direction dependencies. Five factories became container-managed beans across 5 files and 35 insertions; the static construction ladders were deleted, 24 lines then 87. Construction sites were made nameless 4 of 5 in the first slice, 14 of 15 in the second. The straightening step was 21 edits across 3 moves.

The largest mutually-dependent groupTypes
at the start978
after the first slice924
after the second slice851
after straightening814

At every one of those steps the test gate read identically to the baseline: 5,208 tests, no failures, the 344-suite map unchanged.

03

One instruction is not one edit.

An instruction is a structural decision — move this set of types into that module. The edits are everything the compiler then requires afterwards: imports, qualified names, visibility, call sites, module declarations.

One operationEdits it producedWhat it was
move a set into a module5,60327 types — the score module
move a set into a module5,534349 types
move a set into a module4,166252 types
move a set into a module759114 types
relocate one type353repackaging one public class
relocate one type178one data type, to the package its consumers read
relocate one type149one class, restored to its earlier package
relocate one type110one class, export target derived by the tool

The score module is the clearest case. Twenty-seven types were moved, and the operation made 5,603 edits to keep the project compiling. Nobody types 5,603 edits by hand, and nobody reviews them by hand either.

Across the whole run: 548 operation call sites in 276 distinct scripts, drawing on 59 distinct operations, submitted as 369 runs. Those produced 50 commits totalling 1,861 files changed, +8,015 / −4,167. Almost nothing was typed: every Java edit went through an operation except an enumerated handful — two one-line edits, five module descriptors, and eleven repairs of the tooling’s own output.

04

Nine modules the compiler enforces.

1,596 source files became 1,622 across nine modules, each with a dependency list the compiler enforces. collectors is the interesting one: it sits beside core rather than above it, depending on core-model directly.

arrows point to what a module is built after neighborhood77 search252 constraint-streams349 core354 what remains collectors114 beside the core, not above it core-model396 the ninth module domain21 score27 util32 1 622 source files · from one artifact of 1 596
Module.javaexportsDepends on
util322
score275
domain214score
core-model39676util, score, domain
core35434util, score, domain, core-model
collectors1145core-model, util
constraint-streams34910core, util, collectors
search25230core
neighborhood772core, search, constraint-streams, util

The finished state builds clean, including dependency analysis, API comparison and framework augmentation. Eleven consumers of the library now compile against 19% of the codebase where they previously compiled against 45%. That is the return on the exercise: a consumer takes on the part of the library it actually uses, and the compiler holds the line.

05

Regression was controlled against a held build.

A build was held at a fixed commit and never written to. After each significant step the whole project was tested and compared against it module by module rather than on totals. A comparison that never disagrees is not evidence that anything is being checked. This one disagreed twice.

The 91 failures and 72 errors are present in the control itself, so nothing in the run introduced them. The control sits at the commit before the fourth separation, which means the phases before it are not covered by any control.

suitestestsfailureserrors
the control, at the fixed commit5955,9449172
after the first downward separation5955,9449172
a later phase, first attempt5955,9449272
the same phase, after the fix5955,9449172
the largest separation, first attempt5955,94491117
the same separation, after the fix5955,9449172

One extra failure in one phase; 45 extra errors in another. Each time the work stopped, the cause was found, and the numbers returned to the control before anything was built on top. The finished state matches the control exactly. The 91 failures and 72 errors are present in the control itself, so nothing in the run introduced them — though see the limits below on what the control covers.

A large upward separation was expected to break tests that only fail at run time, and the earlier record put that number at 151. The first suite run after the move produced 23 failures and 128 errors, across 22 test classes: 151.

06

The run found a hole in its own checking.

The API comparison gate was configured to match plain classes. It spells an interface, an enum and an annotation type differently, which means it had never analyzed any of them. The configuration was widened and the gate re-run, rather than the conclusion being left as an inference.

The corrected API gateValue
further removals found92 — 66 interfaces, 21 annotation types, 5 enums, across 22 packages
declared removals, before → after55 → 147

Nothing shipped that should not have; the changes were deliberate and on the record. But nothing had been checking them either, and a gate that silently covers less than you think is worth more attention than one that fails loudly.

07

What the checks caught.

Every gate at the finished state is green. Getting there was not a clean line, and the record of where it bent is more useful than the result.

Seventy-five wrong turns were logged, each with its cause. The number being large is the point: it records a process that catches itself rather than one that did not slip.

The clearest lesson in the run was a narrowed gate. For three phases the routine check ran one module’s tests instead of the whole project. Downstream problems accumulated unseen during that window and were paid off later, in one phase, all at once. The check was widened afterwards. A fast gate that covers less is not a cheaper gate.

102 predictions were scored: 84 hit, 6 partial, 12 missed. Eleven of the twelve misses share one shape — the earlier record said an operation could do something and the operation refused. Only one miss concerned the codebase itself. The record’s own conclusion is worth quoting: a record’s numbers replay, and a record’s claims about what a tool can do do not.

48 decisions were logged, 45 settled from measurements and 3 escalated to a person. None was later reversed. The three that went up were scope (replay the whole line or its second half), a trade of assurance against speed (the per-write check recompiled everything downstream), and a change to a published type. That last one is not a technical question, which is exactly why it went up.

08

What the record does not establish.

No baseline exists, in either direction.
There is no measurement of the same work done by people, and none of it done without the engine. The report inverts the question rather than inventing a comparison.
The input-token figure is not usable.
Input is recorded as 141.01M cache-adjusted, in a way the record never defines. It is neither a raw count nor a cost, so it is not quoted here.
Effort is not broken down by phase.
Only per-day rows exist and the phases do not align to days. No statement of the form “this phase cost X” is derivable from the record.
The control does not cover the whole run.
It sits at the commit before the fourth separation, so the phases before it are uncovered. Its 91 failures and 72 errors are not established as pre-existing; the record attributes most of them to the container change made earlier in the same run.
One capability has no evidence here.
No operation chose interface members during this run. The capability may exist; this run cannot be the evidence for it.
The diffstat is a committed diff.
Not authored lines. It includes relocations, generated descriptors and repairs of the tooling’s own output.

If you want to know what this would look like on your own codebase, get in touch.