PBC for Claude Code - Rebuild54 vs Rebuild55 Case Study
- Context
- What We Observed
- Where The Time Went
- Root Cause
- The Fix
- Mapping to the Research
- Findings by Variable
- Open Questions From This Case
Context
This page continues the pbc-4445 / pbc-4849 / pbc-5253 series. The same Darmok scenario ran on consecutive cycles against sheep-dog-grammar, both on 2026-05-26, on branches Rebuild54 and Rebuild55. The pair-range topped the gsheet tab (gid=1837142131) for the run window.
The investigation led to a verified harness bug: the JaCoCo coverage shortlist producer added in #404 (commit abc5e3f, 2026-05-25) has never written a single byte. The Java parser fails on every call with FileNotFoundException: report.dtd, caught silently and returned as null. The narrowing artifact that fix.md step 1 directs Claude to read does not exist on disk, on any run, since the feature shipped.
| Case | |
|---|---|
| Scenario | No component in the first step triggers an error validation |
| Rebuild54 commit | 41cf7898 |
| Rebuild55 commit | e7955df9 |
| Green-phase range | 3.2 min (194,411 ms) — top of gid=1837142131 |
| Files patched (final, post-refactor) | R54 and R55: identical — same two files, same enum name, same detector logic |
| Diagnosed cause | harness — DocumentBuilderFactory default loads external DTDs, JaCoCo’s report.dtd not shipped in report dir, parse throws, silent catch returns null |
Both runs arrived at the same final fix: an additional enum value TEST_STEP_CONTAINER_STEP_OBJECT_NAME_ONLY in TestStepContainerIssueTypes and a guard in TestStepContainerIssueDetector.validateNameOnly that returns the new description when the first step’s component is empty. There is no spec ambiguity here and no sibling-test retry — pure path variance, attributable to the missing shortlist.
A meta-observation from this case: a buggy harness shows up as a noisy baseline, not as an outright run failure. Both runs passed, both committed, both produced equivalent code. The methodology’s signal is the pair-range itself — once it widens past the in-control band, the cause is somewhere in the system the runs share. Here that “somewhere” was an unfixed JAXP default in the harness’s coverage-shortlist producer, masking a feature that shipped but never actually executed. The case-study series doesn’t just catch model variance; it catches infrastructure variance that the build green-light wouldn’t.
What We Observed
| Rebuild54 | Rebuild55 | |
|---|---|---|
| Commit | 41cf7898 |
e7955df9 |
| Phase total | 717,341 ms | 485,600 ms |
| Red phase | 67,876 ms | 75,911 ms |
| Green phase | 419,599 ms | 225,188 ms |
| Refactor phase | 149,718 ms | 130,788 ms |
Green-phase pair-range = 194,411 ms (3:14), the widest on gid=1837142131 for the Rebuild54/Rebuild55 column pair. The local metrics.csv agrees with the sheet to the millisecond — no override divergence.
Both runs ran the same Darmok flow: identify.md then resumed-session fix.md, then /compact, then /rgr-refactor forward sheep-dog-grammar. Single --session-id per scenario throughout.
| R54 session | R55 session | |
|---|---|---|
| Green session UUID | a2286b94-26f7-4193-8178-65a7c8ce22a4 |
b6fe96d2-6066-4999-8a4b-66c234e696dd |
identify.md issued |
11:35:10 EDT (15:35:10Z) | 16:21:49 EDT (20:21:49Z) |
fix.md resumed |
11:35:39 EDT (+0m 28s) | 16:22:20 EDT (+0m 31s) |
| Green: Completed (mojo) | 11:41:49 EDT | 16:25:14 EDT |
| identify wall-clock | ~28 s | ~31 s |
| fix wall-clock (identify-end → Green: Completed) | ~370 s | ~174 s |
The +3 s identify gap is noise on a ~30 s baseline. The +196 s fix-phase gap is the assignable signal and accounts for the entire 194,411 ms pair-range.
Each JSONL ends end_turn cleanly with stop_reason: end_turn on the final assistant message — no timeouts, no recoveries. Both runs passed every gate and committed.
Where The Time Went
The two JSONLs were parsed for the full session span. The green-phase window is from session start through the green mvn-verify pass; the rgr-refactor block sits inside the same JSONL afterward and is included below for completeness, but the pair-range is driven by the green-phase portion.
| R54 (slow) | R55 (fast) | |
|---|---|---|
| Wall-clock (full session) | 10m 26s | 6m 27s |
| Assistant turns | 97 | 72 |
| Thinking blocks | 25 | 17 |
| Output tokens | 13,911 | 8,440 |
| Cache-read tokens | 4.81 M | 2.97 M |
| Total tool calls | 58 | 43 |
| Bash calls | 6 | 6 |
| Read calls | 20 | 13 |
| Glob calls | 13 | 7 |
| Grep calls | 5 | 3 |
| Edit calls | 5 | 5 |
| TodoWrite | 7 | 7 |
| ToolSearch | 1 | 1 |
| Skill | 1 | 1 |
mvn build cycles |
3 (all green-pass + post-refactor) | 3 (all green-pass + post-refactor) |
| Time-to-first-Edit (from fix.md prompt) | 4m 31s | 1m 46s |
The mvn build cycles are identical in count and shape. No retries on either side. The variance lives in pre-edit exploration: R54 made 28 tool calls between the fix.md prompt and its first Edit (≈4:31); R55 made 14 (≈1:46). The +2:45 time-to-first-Edit delta accounts for ~85% of the green-phase pair-range; the remaining ~30s is small-jitter spread across the validate/refactor cycle.
The 14 vs 28 fix-phase tool calls
Both sessions opened with the same shape — calls 1–10 are functionally identical (ToolSearch:TodoWrite, TodoWrite seed, Grep log.txt for errors, TodoWrite, Read jacoco-shortlist.md → file does not exist on both sides, TodoWrite, Read uml-interaction-main.md, Read uml-interaction-test.md, TodoWrite). The divergence point is call 11:
| # | R54 (slow) | R55 (fast) |
|---|---|---|
| 1–10 | (identical: identify phase + fix.md preamble — same 10 calls) | same |
| 11 | Glob **/*Validation*File*Issues* |
Grep "No component in the first step" sheep-dog-grammar |
| 12 | Read .../Validation for File Issues.feature (whole file) |
Grep "No component in the first step" .../Validation for File Issues.feature |
| 13 | Glob **/ValidateActionImpl.java |
Glob **/ValidateActionImpl.java |
| 14 | Read .../ValidateActionImpl.java |
Read .../ValidateActionImpl.java |
| 15 | Glob **/TestStepContainerIssueDetector.java |
Glob **/TestStepContainerIssueDetector.java |
| 16 | Read .../TestStepContainerIssueDetector.java |
Read .../TestStepContainerIssueDetector.java |
| 17 | Glob **/TestStepContainerIssueTypes.java |
Glob **/TestStepContainerIssueTypes.java |
| 18 | Read .../TestStepContainerIssueTypes.java |
Read .../TestStepContainerIssueTypes.java |
| 19 | Glob **/ITestStepContainer.java |
Glob **/StepObjectRefFragments.java |
| 20 | Read .../ITestStepContainer.java |
Read .../StepObjectRefFragments.java |
| 21 | Glob **/StepObjectRefFragments.java |
Glob **/ITestStepContainer.java |
| 22 | Read .../StepObjectRefFragments.java |
Read .../ITestStepContainer.java |
| 23 | Glob **/ITestStep.java |
Glob **/ITestStep.java |
| 24 | Read .../ITestStep.java |
Read .../ITestStep.java (limit=60) |
| 25 | Glob **/StepObjectRefComponentTypes.java |
Edit TestStepContainerIssueTypes.java — first edit |
| 26 | Read .../StepObjectRefComponentTypes.java |
Edit TestStepContainerIssueDetector.java (imports) |
| 27 | Glob **/RGRTest.java |
Edit TestStepContainerIssueDetector.java (guard logic) |
| 28 | Read .../RGRTest.java |
Bash mvn verify -Dtest=RGRTest |
| 29 | Grep "@RGR" src-gen/test/resources/cucumber |
(continues to mvn-pass + skill) |
| 30 | Read .../Validation for Only Issues.feature (peer file) |
— |
| 31 | Glob **/EMFTestObject.java |
— |
| 32 | Read .../EMFTestObject.java |
— |
| 33 | Glob **/*ProcessIssues*Impl.java |
— |
| 34 | Grep "setTestStepListNode|setCreated|TestStepFullName|testStepList" ProcessIssuesAsciidocFile... |
— |
| 35 | Glob **/TestStepNameParser.java |
— |
| 36 | Read .../TestStepNameParser.java |
— |
| 37 | Glob **/StepObjectRefObjectVertexTypes.java |
— |
| 38 | Read .../StepObjectRefObjectVertexTypes.java |
— |
| 39 | Edit TestStepContainerIssueTypes.java — first edit |
— |
R55 reached the first Edit after 14 tool calls, going straight from Read ITestStep.java to Edit TestStepContainerIssueTypes. R54 took 28 tool calls, spending its second-half exploration on artifacts that turned out to be irrelevant to the fix:
StepObjectRefComponentTypes.java— sibling enum, not referenced by the final editRGRTest.javaand the@RGRgrep — runner harness, not modifiedValidation for Only Issues.feature— the peer spec file (different scenario, different observable), opened and read in fullEMFTestObject.java— base test-object class*ProcessIssues*Impl.javaand the stepdef grep — impl-side machineryTestStepNameParser.java— fake/parser helper used by implsStepObjectRefObjectVertexTypes.java— another enum, also not referenced
None of these files were modified by either run’s final fix. R55’s strategy was content-first (Grep for the scenario text, follow the matches); R54’s strategy was filename-pattern-first plus speculative neighbourhood expansion — Glob the next plausible filename, Read it, repeat until the model felt informed.
The first Edits in both runs are nearly identical (modulo enum name, which the post-mvn refactor step then normalizes to the same final value — see below).
The post-mvn refactor cycle
Both runs reach a green mvn-verify pass on the first Edit batch. Both then proceed: Skill rgr-refactor forward sheep-dog-grammar → validate_main.py → finds one violation: the enum name does not match the {TYPE}_{ASSIGNMENT}_{SCOPE} convention required by the UML uml-class-TypeIssueTypes.md regex. Both runs rename to the same compliant name:
| R54 first-Edit enum | R55 first-Edit enum | Final enum (after refactor cycle) | |
|---|---|---|---|
| Name | TEST_STEP_CONTAINER_FIRST_STEP_COMPONENT_ONLY |
TEST_STEP_CONTAINER_FIRST_STEP_COMPONENT |
TEST_STEP_CONTAINER_STEP_OBJECT_NAME_ONLY |
| Description | "The first step must have a component" |
"The first step must have a component" |
"The first step must have a component" |
R54 wrote it once with the _ONLY suffix (which still failed the assignment-segment regex because FIRST_STEP_COMPONENT is not in the valid assignment set); R55 wrote it without _ONLY (also failed for the same regex reason). Both converged on STEP_OBJECT_NAME_ONLY after the refactor pass — STEP_OBJECT_NAME is a valid assignment, ONLY is a valid scope, and the description text describes the same observable.
The final commit diffs are equivalent: same two files (TestStepContainerIssueTypes.java, TestStepContainerIssueDetector.java), same enum name, same detector logic shape. The pair-range is therefore not driven by spec ambiguity, search strategy producing different fixes, or sibling-test regression — it is pure exploration overhead before the first byte of the same edit.
Root Cause
Both runs called Read /home/farhan/git/sheep-dog-main/sheep-dog-core/sheep-dog-grammar/target/darmok/jacoco-shortlist.md and both received:
File does not exist. Note: your current working directory is /home/farhan/git/sheep-dog-main.
The shortlist is the artifact fix.md step 1 directs Claude to read first — “if populated it narrows your search to the classes other passing tests in this family already exercise plus the production classes those tests cover.” It is produced by GreenPhase.computeJacocoShortlist(targetDir), which parses target/site/jacoco-with-tests/jacoco.xml and writes a single-table list of classes with covered line ranges. The producer was added in commit abc5e3f on 2026-05-25 — before both Rebuild54 and Rebuild55 (2026-05-26), so the code was present in the build.
The mojo log on 2026-05-26 has zero occurrences of Green: Writing jacoco-shortlist.md:
$ grep -c "Green: Writing jacoco-shortlist" /logs/darmok.mojo.2026-05-26.log
0
GreenPhase.computeJacocoShortlist returned null for every scenario that day. The producer has three null exits:
if (!Files.exists(jacocoXml)) return null;catch (Exception e) { return null; }— silently swallowed parse failureif (classCoveredLines.isEmpty()) return null;
jacoco.xml is present on disk (517 KB on the case-study run’s checkout; produced by the red phase’s mvn verify -Dtest=<RunnerClass>, since the pom binds jacoco-ant’s report-with-tests execution to the verify phase). The third path can’t fire either — sheep-dog-grammar has 129 sourcefiles and many covered lines after a green test run, not zero.
The actual failing branch is the silent catch, reproduced directly against the on-disk file:
$ cat TestJacocoParse.java
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
public class TestJacocoParse {
public static void main(String[] args) throws Exception {
var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new File(args[0]));
System.out.println("OK");
}
}
$ java TestJacocoParse .../target/site/jacoco-with-tests/jacoco.xml
PARSE FAILED: java.io.FileNotFoundException:
.../target/site/jacoco-with-tests/report.dtd (No such file or directory)
JaCoCo’s XML report opens with a DOCTYPE:
<!DOCTYPE report PUBLIC "-//JACOCO//DTD Report 1.1//EN" "report.dtd">
report.dtd is a relative system identifier. Java’s default DocumentBuilderFactory resolves it next to the XML file. JaCoCo doesn’t ship report.dtd in the report directory (ls target/site/jacoco-with-tests/ confirms — index.html, jacoco.csv, jacoco.xml, per-package directories, and nothing else). The parse throws FileNotFoundException on every call. The default JAXP parser would do this on every machine — Linux, Mac, Windows, CI, local. It has been throwing on every Darmok green-phase since #404 landed.
The exception was swallowed by catch (Exception e) { return null; } at the bottom of the method, and the surrounding code branches on if (shortlist != null) — so null silently means “no shortlist this round, no log entry, fall back to UML guidance.” That fallback is what fix.md is told to lean on when the shortlist is empty; the prompt has no way to distinguish “shortlist absent because nothing’s covered” from “shortlist absent because the producer threw.”
Why every scenario, not just one
There is no per-scenario filtering in computeJacocoShortlist — it parses whatever jacoco.xml is at target/site/jacoco-with-tests/jacoco.xml and emits a coverage table for the whole run. The DOCTYPE is identical across jacoco’s output, so the parse fails the same way on every call. The producer is a per-run no-op disguised as a per-scenario no-op.
Why this wasn’t caught by tests
The producer’s unit-test coverage doesn’t exercise the actual JaCoCo XML output — it would have needed an end-to-end repro reading a real jacoco.xml from a green-phase mvn verify, or a fixture file containing the DOCTYPE. The producer ships, runs, returns null, and nothing fails. The only downstream consumer is Claude, which sees “file doesn’t exist” and falls back to UML guidance — also not a failure mode that bubbles up.
The way this surfaced was the pair-range methodology: a feature shipped to narrow Claude’s search across runs, yet the inter-run variance on shared scenarios stayed in the same band. That’s the signal — the fix shipped but the variance it was supposed to reduce didn’t move.
Place: harness — producer. The fix lives in GreenPhase.computeJacocoShortlist.
The Fix
Patch applied (two edits to GreenPhase.java):
// Disable external DTD loading on the parser.
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
Document doc = factory.newDocumentBuilder().parse(jacocoXml.toFile());
And remove the silent catch — let any producer failure bubble up with an ERROR log at the call site:
String shortlist;
try {
shortlist = computeJacocoShortlist(targetDir);
} catch (Exception e) {
mojoLog.error("Green: jacoco-shortlist.md producer failed: "
+ e.getClass().getName() + ": " + e.getMessage());
throw e;
}
Reproduced the fix against the on-disk jacoco.xml: PARSED OK, root: report — packages: 16, sourcefiles: 129. mvn compile on darmok-maven-plugin succeeds.
The Files.exists(jacocoXml) path is also tightened: a missing jacoco.xml after the red phase’s mvn verify ran is a fatal upstream invariant violation (the jacoco-ant report-with-tests execution failed to bind, the target dir was wiped between phases, etc.), not a state to silently degrade through. Throws IllegalStateException with the resolved path. The classCoveredLines.isEmpty() null-return is kept — that represents a legitimate “the runner exercised nothing” state, which is data, not a bug. The catch-and-swallow path is removed entirely: “I couldn’t parse the file you handed me” is never a state worth pressing on through.
Place: harness — producer. No spec change, no prompt change, no test-side change required. The producer now does what it was claimed to do in #404’s commit message.
Mapping to the Research
| Predicted (pbc-research) | Observed |
|---|---|
| Wide pair-range | green-phase +3:14, top of gid=1837142131 |
| Each run individually within in-control band | not flagged out-of-control by the gsheet on this tab, but the widest on the pair |
| Both runs pass the test | yes |
| Two work-trees differ | no — final commits are equivalent (same two files, same enum name, same logic). Pure path variance, pbc-4849-style. |
The signature matches pbc-4849 (same files patched, identical final artifacts, pure path variance) and not pbc-4445 (different files patched, both valid) or pbc-5253 (two-layer: search + sibling-regression). The cost site is upstream of where pbc-4849 sat — pbc-4849 diagnosed which command Claude typed given a narrowing prompt; this case diagnosed why Claude was free to type anything at all because the narrowing artifact never existed.
Findings by Variable
Each subsection records this run’s findings about one Wheeler variable. Read the same heading across the run sequence to see how our understanding of that variable evolved.
green time pair range
The green-phase pair-range topped gid=1837142131 at 194,411 ms (3:14) — yet both runs produced identical final commits (same two files, same enum name, same detector logic). A wide pair with identical output is the headline of this case: it exposed a harness bug, not model variance. The JaCoCo coverage-shortlist producer added in #404 never wrote a byte — a report.dtd FileNotFoundException was swallowed silently by catch (Exception e) { return null; }, so the narrowing artifact fix.md step 1 reads never existed on any run since the feature shipped. With no shortlist to narrow the search, both runs reverted to wide pre-edit exploration (R54 took 28 tool calls to first Edit, R55 took 14), and the pair-range stayed in the same band the feature was meant to shrink. The pair-range methodology catching a buggy harness — a feature that shipped but never executed, invisible to CI because both runs still passed — is what makes this case distinct.
green time pair range moving range
No finding this run.
green time
The slow half (R54) ran a full session of 10m 26s vs R55’s 6m 27s; the assignable signal was the +196 s fix-phase gap (R54 ~370 s, R55 ~174 s), accounting for the entire pair-range. The variance was pure exploration overhead before the first byte of the same edit — no timeouts, no recoveries, no contradiction; both JSONLs ended end_turn cleanly and both committed. R54’s +2:45 time-to-first-Edit delta accounted for ~85% of the green-phase pair-range, the rest small-jitter across the validate/refactor cycle.
green time moving range
No finding this run.
scale & green tokens
R54 emitted 13,911 output tokens vs R55’s 8,440, tracking the heavier exploration (20 vs 13 Read calls, 13 vs 7 Glob calls, 4.81 M vs 2.97 M cache-read tokens). The two runs ran at different times of day (R54 issued identify.md at 11:35 EDT, R55 at 16:21 EDT), so a server/time-of-day decode-rate confound is present, but the dominant delta here is the tool-call count driven by the missing shortlist, not throughput noise.
warm-up position
No finding this run.
Open Questions From This Case
- The patched producer will start logging at INFO (“Green: Writing jacoco-shortlist.md”) on the next Darmok run. The follow-up question is whether the resulting shortlist actually shrinks the pair-range on this scenario. Re-running Rebuild56/Rebuild57 after the patch lands would tell — same Test-Case, same model, but
fix.mdstep 1 is no longer a no-op. - A grep for
DocumentBuilderFactory.newInstance()across the broader sheep-dog tree would surface other JAXP-default-DTD-loading sites. The asciidoc/UML pipeline parses XML in several places; whether any of those hit the same trap is worth a 5-minute audit. - The patch also makes a missing
jacoco.xmlfatal — an upstream invariant violation rather than a silently-skipped state. Worth a separate scan of red-phase / pom configuration to confirm jacoco-ant’sreport-with-testsexecution always fires when red’smvn verifyruns, so the new throw never surfaces in normal operation. - The two-prompt split (
identify.mdthenfix.md) is unchanged. On this case, identify ran short (28–31 s) and added no signal; fix carried 100% of the variance. If a future case shows identify-side variance again, collapsing back into one prompt may be re-litigated.