refactor(provider): put the JNI provider's pure half on the PIT gate, and close TODO #51 - #206
Conversation
… reach it TODO #51. The mutation gate could not cover LlamaCppJniAiGenerationProvider, and the reason was structural rather than a missing test: PIT re-runs every test covering a mutated line, and while this logic sat next to model() the only way to put any of it on targetClasses was to put model() there too -- a ~100-line ModelParameters chain whose sole exerciser is LlamaCppJniKnobSweepTest, 36 cases that each load a GGUF. Measured here: that class takes 22.9 s, and the provider test another 29.8 s. Excluding those tests instead does not work, which is worth recording because it is the obvious first idea: model()'s mutants would then have no coverage at all, and a NO_COVERAGE mutant fails a threshold-100 gate exactly like a survivor. The only arrangement that works is one where the gated class is covered end to end by tests that need no model -- hence a split along "touches the native handle", not along any tidier conceptual line. Moved to the new LlamaCppJniProviderSupport (config + prompt support in, no native handle): buildInferenceParameters, buildChatTemplateKwargs, warnOnTruncatedAnswer, logPromptCacheReuse, the static lazyMode/cacheType resolvers and their two known*Values renderers, plus the five constants only they use. The provider keeps model(), generate(), chatResponse(), generateWithTimings() and close(), and delegates. Two consequences worth knowing rather than discovering later: * The provider no longer logs anything itself and lost its LOGGER; every log line this component emits now comes from LlamaCppJniProviderSupport. The ListAppender assertions follow that move. * lazyMode/cacheType were reachable only from model(). Checked before moving them that the same-named calls in LlamaCppJniConfigFactory are builder setters on the config, not these resolvers, so the move has exactly one caller. The test class is split the same way: LlamaCppJniProviderSupportTest holds the 20 model-free cases, LlamaCppJniAiGenerationProviderTest keeps the two real-provider ones behind NativeLlamaAvailability.assumeAvailable(). Every support case builds its config over "/does/not/exist.gguf" -- not a shortcut but the contract the class exists to keep, since model() is lazy and none of these methods touches it. Verified: 654 tests, 0 failures -- the same count as before the split, so nothing was dropped in the move. Baseline PIT for comparison, on this machine: 807/807 killed, 100%, 11:34 min, 1300 test runs. excludedTestClasses is deliberately NOT set yet. An exclusion removes that class from mutant matching for every target class, not just this one, so at 807/807 it could create survivors elsewhere. Measure what the extraction alone buys first; add exclusions only if the runtime demands it. That is what the TODO entry's "measure before committing to it" asks for, and the measurement is still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
Putting LlamaCppJniProviderSupport on targetClasses turned 807/807 into 825/830: the class contributes 23 mutations and five of them survived. They are real test gaps, not equivalent mutants, and every one of them was invisible before -- which is the whole argument of TODO #51: while the class generated no mutants, the gate number could not move whether the tests were adequate or not. What survived, and what now kills it: * known{CacheType,LazyMode}Values, `known.length() > 0` -> `>= 0`. The renderer emits its separator from the second value on; with the mutant the message reads "expected one of: , f32, ...". Both rejection tests asserted containsString("f32, f16, bf16, q8_0"), which sits in the MIDDLE of the list and cannot see a stray leading separator. Each now also anchors on "expected one of: <first value>". * `config.seed() >= 0`, both the boundary and the negation. The seed had no model-free coverage at all. Two tests: an unconfigured run sends no seed (the sentinel is -1 = "random per request", and forwarding it would pin every run to seed -1 and destroy the randomness upstream provides), and seed 0 IS sent (0 is a legitimate seed, not a second sentinel, so the guard must be >= 0). Same shape as the penalty-window pair already there. * `config.drySequenceBreakers().isEmpty()`, negated. An empty list means "keep the binding's own default set", not "clear it", so an unconfigured run must send nothing; a configured one must arrive. Two tests, one per branch. 658 tests, 0 failures. The PIT re-run confirming 830/830 is next; this commit is the test work, not the confirmation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
The entry asked for a class on the PIT gate and, before committing to it, a measurement -- because the pure logic was covered by model-backed tests and PIT re-runs every test covering a mutated line. Both are done, and the answer is not the one the entry guessed. Measured on one machine, same suite, three runs: 807 mutations / 807 killed / 11:34 min before 830 mutations / 825 killed / 15:57 min with the split, five survivors 830 mutations / 830 killed / 12:14 min with the survivors killed So the extension costs about 40 s for 23 mutations, and `excludedTestClasses` -- which the entry expected to be necessary -- is not used. It would remove a test class from mutant matching for EVERY target class, not just this one, and the number says it buys nothing here. The 15:57 reading in the middle is not the cost of the split: PIT exhausts every covering test for a mutant that never dies, while a killed one stops at the first failure, so survivors inflate the clock. CLAUDE.md now carries the boundary rule rather than only the outcome, because the split is load-bearing and easy to undo by accident: a pure method moved back into the provider silently drops off the gate, and an impure one moved into the support class reds it. The rule is "touches the native handle". TODO.md's entry is deleted rather than annotated, per that file's own header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
| /** | ||
| * Everything {@link LlamaCppJniAiGenerationProvider} does that does not touch the JNI binding's | ||
| * native side: turning a {@link AiGenerationRequest} plus a {@link LlamaCppJniConfig} into | ||
| * {@link InferenceParameters}, resolving configured CLI strings to the binding's enums, and | ||
| * reporting on a {@link ChatResponse} that has already come back. | ||
| * | ||
| * <p><b>Why this is a class of its own.</b> It is not a style preference; it is what makes the | ||
| * mutation gate reach these lines at all. PIT re-runs every test covering a mutated line, and while | ||
| * this logic sat next to {@code model()} the only way to put any of it on {@code targetClasses} was | ||
| * to put {@code model()} there too -- a ~100-line {@code ModelParameters} chain whose sole exerciser | ||
| * is {@code LlamaCppJniKnobSweepTest}, 36 cases that each load a GGUF. Excluding those tests instead | ||
| * does not work either: {@code model()}'s mutants then have no coverage at all, and a | ||
| * {@code NO_COVERAGE} mutant fails a threshold-100 gate exactly like a survivor. Separating the pure | ||
| * half is the only arrangement where the gated class is covered end to end by tests that need no | ||
| * model, which is why the split runs along "touches the native handle" and not along any tidier | ||
| * conceptual line.</p> | ||
| * | ||
| * <p>That gap was not hypothetical. The {@code dry_penalty_last_n} regression in 1.1.0/1.1.1 killed | ||
| * every generation before a token was produced, and the gate read a stable 775/775 straight through | ||
| * it and through its fix -- because the class generated no mutants, neither the defect nor the tests | ||
| * that now cover it could move the number. Stability was never evidence of anything here.</p> | ||
| * |
There was a problem hiding this comment.
Excellent Javadoc. This explanation of why the split exists along the "touches the native handle" boundary is both precise and load-bearing. The history of the dry_penalty_last_n regression (1.1.0/1.1.1) passing a 775/775 gate is a perfect concrete example of why mutation testing alone cannot replace proper architectural boundaries.
| public void buildInferenceParameters_configuredDrySequenceBreakers_arePassedThrough() { | ||
| // arrange | ||
| final LlamaCppJniConfig configured = LlamaCppJniConfig.builder("/does/not/exist.gguf") | ||
| .drySequenceBreakers(Collections.singletonList("\\n")) | ||
| .build(); | ||
|
|
||
| // act | ||
| final String json = providerWith(configured) | ||
| .buildInferenceParameters(request("class A {}")) | ||
| .toString(); | ||
|
|
||
| // assert | ||
| assertThat(json, containsString(PARAM_DRY_SEQUENCE_BREAKERS)); | ||
| } |
There was a problem hiding this comment.
Strong test design: pinning the boundary from multiple angles. The empty list test confirms an empty configured set does NOT get forwarded (preserving the binding's default), while the configured test asserts it does reach the request. This pattern is repeated throughout for seed, penalty windows, and enable_thinking — each test name explicitly documents what boundary it verifies and why the guard matters.
|
|
||
| /** | ||
| * The regression itself. Both penalty windows default to {@code -1}, and the binding rejects any | ||
| * negative window outright ({@code IllegalArgumentException}) because llama.cpp b10273 dropped | ||
| * "{@code -1} = context size". Forwarding one unguarded therefore throws before a single token is | ||
| * produced -- which is exactly what {@code dry_penalty_last_n} did in 1.1.0 and 1.1.1. | ||
| */ | ||
| @Test | ||
| public void buildInferenceParameters_defaultConfig_doesNotThrowOnTheNegativeSentinels() { | ||
| // arrange | ||
| final LlamaCppJniConfig defaults = | ||
| LlamaCppJniConfig.builder("/does/not/exist.gguf").build(); | ||
|
|
||
| // act / assert | ||
| Assertions.assertDoesNotThrow(() -> providerWith(defaults).buildInferenceParameters(request("class A {}"))); | ||
| } |
There was a problem hiding this comment.
This test and its comment are documenting exactly why the refactoring matters: the native binding rejects negative penalty windows, so forwarding a -1 sentinel kills every generation before a token is produced. The dry_penalty_last_n regression of 1.1.0/1.1.1 did exactly this, and the gate read 775/775 straight through it because the provider wasn't on targetClasses. This extraction puts the guard code itself under mutation testing where it belongs.
| <param>net.ladenthin.srcmorph.config.SrcMorphConfiguration</param> | ||
| <param>net.ladenthin.srcmorph.provider.LlamaCppJniConfig</param> | ||
| <param>net.ladenthin.srcmorph.provider.LlamaCppJniConfigFactory</param> | ||
| <param>net.ladenthin.srcmorph.provider.LlamaCppJniProviderSupport</param> |
There was a problem hiding this comment.
✓ Correctly added to targetClasses for PIT mutation testing at 100% threshold. The new class holds the guardcode that prevented the 1.1.0/1.1.1 regression from being caught, so its presence on the gate directly closes the coverage gap that existed before.
Code Review: APPROVED ✓This is a well-executed refactor that closes a real gap in mutation testing coverage while maintaining strict code quality standards. StrengthsArchitecture & Design
Test Coverage (530 lines, 4 new test methods)
Documentation
Code Quality
Observations (not blockers)
SummaryThis PR directly closes a defect in the mutation-testing gate that allowed the |
Summary
LlamaCppJniAiGenerationProvideris the one production class where a defect has actually reached users, and it was not ontargetClasses. That is why the gate read a stable 775/775 straight through the 1.1.0-eradry_penalty_last_nregression and through its fix: a class that generates no mutants cannot move the number, whether the tests are adequate or not. TODO #51 asked to fix that, and — before committing to it — to measure, because the pure logic is also exercised by model-backed tests and PIT re-runs every test covering a mutated line.Both are done. The measurement's answer is not the one the TODO guessed.
Why extraction, and not just
excludedTestClassesChecked rather than assumed. Putting the provider on the gate unchanged and excluding the model-backed test classes does not work:
model()'s mutants would then have no coverage at all, and aNO_COVERAGEmutant fails a threshold-100 gate exactly like a survivor. Only a class whose every line is covered model-free can go on the gate — so the split is forced, not a matter of taste.provider.LlamaCppJniProviderSupport(new) takes everything that does not touch the native handle:buildInferenceParameters,buildChatTemplateKwargs,warnOnTruncatedAnswer,logPromptCacheReuse, the staticlazyMode/cacheTyperesolvers and their twoknown*Valuesrenderers. The provider keepsmodel(),generate(),chatResponse(),generateWithTimings(),close()— and loses itsLOGGER, since it no longer logs anything itself.The boundary is "touches the native handle", and it is load-bearing: a pure method moved back into the provider silently drops off the gate; an impure one moved into the support class reds it.
CLAUDE.mdnow says so.The tests split the same way. Only two of the old test class's cases were model-bound (
generate_realProvider_*,generateWithTimings_realProvider_*, both behindNativeLlamaAvailability.assumeAvailable()); every test of the pure methods already built its provider overLlamaCppJniConfig.builder("/does/not/exist.gguf"), sincemodel()is lazy. So the pure paths already had full model-free coverage — they just had no mutants to prove it.The measurement
So the extension costs ~40 s for 23 mutations, and
excludedTestClasses— which the TODO expected to be necessary — is not used. It would remove a test class from mutant matching for every target class, not just this one, and the number says it buys nothing. The 15:57 reading in the middle is not the cost of the split: PIT exhausts every covering test for a mutant that never dies, while a killed one stops at the first failure, so survivors inflate the clock. Worth stating because I misreported that figure once before re-running.The five survivors — all real gaps, none equivalent
known{CacheType,LazyMode}Values,known.length() > 0→>= 0. The renderer emits its separator from the second value on; with the mutant the message readsexpected one of: , f32, …. Both rejection tests assertedcontainsString("f32, f16, bf16, q8_0"), which sits in the middle of the list and cannot see a stray leading separator. Each now also anchors on"expected one of: <first value>".config.seed() >= 0, boundary and negation. The seed had no model-free coverage at all. Two tests: an unconfigured run sends no seed (the sentinel is-1= "random per request"; forwarding it would pin every run to seed −1 and destroy the randomness upstream provides), and seed 0 is sent — 0 is a legitimate seed, not a second sentinel, so the guard must be>= 0. Same shape as the penalty-window pair already there.config.drySequenceBreakers().isEmpty(), negated. An empty list means "keep the binding's default set", not "clear it". One test per branch.Test plan
mvn -f srcmorph/pom.xml test-compile pitest:mutationCoverage→ 830/830 killed, 100 %, 0 mutations with no coverage, test strength 100 %,BUILD SUCCESSspotless:applyrun; working tree clean afterwardsmaindeliberately pinsnet.ladenthin:llama:5.2.0, which Central does not carry yet, so resolution fails before the first test compiles. That is unchanged by this PR and is tracked separately. To verify locally, install jllamamainas5.2.0first (versions:set -DnewVersion=5.2.0→install -pl llama -am→git checkout -- .), which is how the numbers above were produced.CLAUDE.md807 → 830 plus the boundary rule and the measurement;TODO.md's entry deleted rather than annotated, per that file's own header.Related issues / PRs
Closes TODO #51. Supersedes the abandoned
claude/pit-provider-measureprobe branch, which took the "gate the provider as-is and measure" route — that branch's approach is the one refuted above, and it can be deleted once this lands.Checklist
CONTRIBUTING.mdandCODE_OF_CONDUCT.md🤖 Generated with Claude Code
https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
Generated by Claude Code