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.
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.
- 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:
| Claim | Stated | Found on disk |
|---|---|---|
| distinct scripts | 276 | 276 .py files |
| runs submitted | 369 | 369 archived artefacts, numbered 1–369 |
| gaps in the run sequence | — | none |
| test suites in the baseline | 344 | 344 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.
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.
| Scenario | Types no longer needed | Types freed | Largest cycle after |
|---|---|---|---|
| A — the nameless-able concrete types | 49 | 185 | 766 |
| B — A, plus the score-director family and the solver factory | 54 | 193 | 764 |
| B, plus one further factory | 55 | 340 | 529 |
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 group | Types |
|---|---|
| at the start | 978 |
| after the first slice | 924 |
| after the second slice | 851 |
| after straightening | 814 |
At every one of those steps the test gate read identically to the baseline: 5,208 tests, no failures, the 344-suite map unchanged.
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 operation | Edits it produced | What it was |
|---|---|---|
| move a set into a module | 5,603 | 27 types — the score module |
| move a set into a module | 5,534 | 349 types |
| move a set into a module | 4,166 | 252 types |
| move a set into a module | 759 | 114 types |
| relocate one type | 353 | repackaging one public class |
| relocate one type | 178 | one data type, to the package its consumers read |
| relocate one type | 149 | one class, restored to its earlier package |
| relocate one type | 110 | one 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.
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.
| Module | .java | exports | Depends on |
|---|---|---|---|
| util | 32 | 2 | — |
| score | 27 | 5 | — |
| domain | 21 | 4 | score |
| core-model | 396 | 76 | util, score, domain |
| core | 354 | 34 | util, score, domain, core-model |
| collectors | 114 | 5 | core-model, util |
| constraint-streams | 349 | 10 | core, util, collectors |
| search | 252 | 30 | core |
| neighborhood | 77 | 2 | core, 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.
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.
| suites | tests | failures | errors | |
|---|---|---|---|---|
| the control, at the fixed commit | 595 | 5,944 | 91 | 72 |
| after the first downward separation | 595 | 5,944 | 91 | 72 |
| a later phase, first attempt | 595 | 5,944 | 92 | 72 |
| the same phase, after the fix | 595 | 5,944 | 91 | 72 |
| the largest separation, first attempt | 595 | 5,944 | 91 | 117 |
| the same separation, after the fix | 595 | 5,944 | 91 | 72 |
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.
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 gate | Value |
|---|---|
| further removals found | 92 — 66 interfaces, 21 annotation types, 5 enums, across 22 packages |
| declared removals, before → after | 55 → 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.
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.
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.
