diff --git a/CLAUDE.md b/CLAUDE.md
index 9afde52..e067a97 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -533,12 +533,36 @@ See [`../workspace/policies/ci-test-diagnostics.md`](../workspace/policies/ci-te
See [`../workspace/policies/pit-mutation-testing.md`](../workspace/policies/pit-mutation-testing.md).
Run PIT with the lifecycle prefix. Reactor-wide (what CI does):
`mvn test-compile org.pitest:pitest-maven:mutationCoverage`; or scoped to one module with
-`-f srcmorph/pom.xml`. All three modules gate at `mutationThreshold` 100 — `srcmorph` (807 mutations),
+`-f srcmorph/pom.xml`. All three modules gate at `mutationThreshold` 100 — `srcmorph` (830 mutations),
`srcmorph-maven-plugin` (62, the five mojo classes) and `srcmorph-cli` (16). The CLI's
`Main.main(String[])` is the one documented exclusion: it is the process entry point, and the
`smoke-fatjar` release-gating job already runs the real `java -jar` artifact and asserts
`Main#run end.` in its output, which is an end-to-end check a unit mutant cannot reach.
+**`LlamaCppJniAiGenerationProvider` is split so the gate can reach it, and the split is load-bearing.**
+Everything the provider does that does not touch the native handle lives in
+`provider.LlamaCppJniProviderSupport`, which is on the gate; the provider itself is not. That is not
+tidiness. PIT re-runs every test covering a mutated line, and while the pure logic sat next to
+`model()` the only way to gate any of it was to gate `model()` too -- a ~100-line `ModelParameters`
+chain whose sole exerciser is `LlamaCppJniKnobSweepTest`, 36 cases that each load a GGUF (22.9 s).
+Excluding those tests instead does not work: `model()`'s mutants would then have no coverage, and a
+`NO_COVERAGE` mutant fails a threshold-100 gate exactly like a survivor. **Keep the boundary at
+"touches the native handle"** -- moving a pure method back into the provider silently drops it off
+the gate, and moving an impure one into the support class reds it.
+
+The extension paid for itself immediately: the class contributed 23 mutations and **five survived**,
+all real gaps (the two `known*Values` separator boundaries, both `seed` guards, and the
+`drySequenceBreakers` branch). None could have been seen before, because a class that generates no
+mutants cannot move the number -- which is exactly why the gate read a stable 775/775 straight
+through the 1.1.0-era `dry_penalty_last_n` regression and through its fix.
+
+Cost, measured on one machine rather than estimated: 11:34 min at 807 mutations before, **12:14 min
+at 830 after** -- about 40 s for 23 mutations. `excludedTestClasses` was considered and is **not**
+used: it would remove a test class from mutant matching for *every* target class, not just this one,
+and the measurement says it is not needed. (The intermediate 15:57 min reading was inflated by the
+five survivors: PIT exhausts every covering test for a mutant that never dies, while a killed one
+stops at the first failure.)
+
**Two classes are permanently off the gate, and this is worth not re-litigating.** Both have
survivors that are *equivalent mutants*, unkillable through the public API rather than merely
untested. `document.AiMdHeaderCodec`: the colon guard in `read` is reached only after
diff --git a/TODO.md b/TODO.md
index 78d4ae1..ef782cf 100644
--- a/TODO.md
+++ b/TODO.md
@@ -10,20 +10,6 @@ everything below is genuinely still open.
## Open
-- **Put `provider.LlamaCppJniAiGenerationProvider` on the PIT gate.** It is the one production class
- where a defect has actually reached users, and it is *not* on the `targetClasses` list — which is
- why the gate stayed at 775/775 through the 1.1.0-era `dry_penalty_last_n` regression and through
- its fix: the class generates no mutants, so neither the bug nor the tests that now cover it move
- the number. Do not read that stability as reassurance; PIT structurally could not have caught this.
- It is now worth adding, because the class finally has model-free coverage of the parts that matter
- (`buildInferenceParameters`, `warnOnTruncatedAnswer`, `logPromptCacheReuse`, `lazyMode`,
- `cacheType`). **Measure before committing to it**: the real-model tests take ~4-16 s each, and PIT
- re-runs every test covering a mutated line, so mutants in `model()` — the long `ModelParameters`
- chain — could make the run far slower than the current few minutes. If it does, the answer is
- probably `excludedTestClasses` for the real-model tests plus mutants restricted to the pure paths,
- not dropping the idea. Deliberately out of scope for 1.2.0: it is a build-time question, not a
- correctness one.
-
- **The sixteen GPU classifier fat jars are verified structurally, never launched.** Since 1.2.0
`.github/verify-classifier-fatjars.sh` asserts each is the artifact its name claims (one jar per
classifier, a native for the promised OS/arch, a native set that differs from the default jar's, so
diff --git a/srcmorph/pom.xml b/srcmorph/pom.xml
index 755a7b2..54c40e5 100644
--- a/srcmorph/pom.xml
+++ b/srcmorph/pom.xml
@@ -720,6 +720,7 @@ SPDX-License-Identifier: Apache-2.0
net.ladenthin.srcmorph.config.SrcMorphConfiguration
net.ladenthin.srcmorph.provider.LlamaCppJniConfig
net.ladenthin.srcmorph.provider.LlamaCppJniConfigFactory
+ net.ladenthin.srcmorph.provider.LlamaCppJniProviderSupport
net.ladenthin.srcmorph.engine.EngineSupport
net.ladenthin.srcmorph.engine.GenerateResult
net.ladenthin.srcmorph.engine.CalibrationReport
diff --git a/srcmorph/src/main/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProvider.java b/srcmorph/src/main/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProvider.java
index 328fa16..e4dd002 100644
--- a/srcmorph/src/main/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProvider.java
+++ b/srcmorph/src/main/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProvider.java
@@ -4,30 +4,20 @@
package net.ladenthin.srcmorph.provider;
import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.ToString;
import net.ladenthin.llama.LlamaModel;
-import net.ladenthin.llama.args.CacheType;
import net.ladenthin.llama.args.FlashAttn;
-import net.ladenthin.llama.args.LazyMode;
import net.ladenthin.llama.args.ReasoningFormat;
import net.ladenthin.llama.json.ChatResponseParser;
-import net.ladenthin.llama.parameters.InferenceParameters;
import net.ladenthin.llama.parameters.ModelParameters;
import net.ladenthin.llama.value.ChatResponse;
-import net.ladenthin.llama.value.Pair;
import net.ladenthin.llama.value.Timings;
-import net.ladenthin.llama.value.Usage;
import net.ladenthin.srcmorph.document.AiGenerationRequest;
import net.ladenthin.srcmorph.prompt.AiPromptSupport;
import net.ladenthin.srcmorph.support.Java8CompatibilityHelper;
import org.jspecify.annotations.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
/**
* {@link AiGenerationProvider} implementation backed by the {@code net.ladenthin:llama}
@@ -42,11 +32,6 @@
@ToString
public final class LlamaCppJniAiGenerationProvider implements AiGenerationProvider, AutoCloseable {
- private static final Logger LOGGER = LoggerFactory.getLogger(LlamaCppJniAiGenerationProvider.class);
-
- /** OpenAI finish reason meaning "the token budget ran out", as opposed to {@code "stop"}. */
- private static final String FINISH_REASON_LENGTH = "length";
-
private final LlamaCppJniConfig config;
// Native llama.cpp model handle — its toString prints native pointer / internal
@@ -58,26 +43,18 @@ public final class LlamaCppJniAiGenerationProvider implements AiGenerationProvid
private @Nullable LlamaModel model;
private final AiPromptSupport promptSupport;
- private final AiCompletionParser completionParser = new AiCompletionParser();
- private final ChatResponseParser chatResponseParser = new ChatResponseParser();
- private final Java8CompatibilityHelper compatibilityHelper = new Java8CompatibilityHelper();
/**
- * Fixed llama.cpp server slot every request is pinned to. The model is loaded once and reused
- * for all files; pinning each request to the same slot lets the prompt cache
- * ({@code cache_prompt}) reuse the shared prompt-template prefix's KV across files instead of
- * re-prefilling it for every file. Reuse is exact, so generated output is unchanged.
+ * Everything this provider does that does not touch the native handle. Split out so the
+ * mutation gate can reach it: see {@link LlamaCppJniProviderSupport}'s own javadoc for why
+ * the boundary runs where it does.
*/
- private static final int REUSE_SLOT_ID = 0;
-
- /** Chat-template kwarg controlling Qwen-style thinking (ignored by non-Qwen templates). */
- private static final String ENABLE_THINKING_KWARG = "enable_thinking";
-
- /** Chat-template kwarg for the gpt-oss reasoning-effort level (ignored by non-gpt-oss templates). */
- private static final String REASONING_EFFORT_KWARG = "reasoning_effort";
+ @ToString.Exclude
+ private final LlamaCppJniProviderSupport support;
- /** Number of chat-template kwargs put into the map (used for presizing). */
- private static final int CHAT_TEMPLATE_KWARG_COUNT = 2;
+ private final AiCompletionParser completionParser = new AiCompletionParser();
+ private final ChatResponseParser chatResponseParser = new ChatResponseParser();
+ private final Java8CompatibilityHelper compatibilityHelper = new Java8CompatibilityHelper();
/**
* Creates a new {@link LlamaCppJniAiGenerationProvider}; the GGUF model is loaded lazily on the first generate(...) call.
@@ -88,45 +65,14 @@ public final class LlamaCppJniAiGenerationProvider implements AiGenerationProvid
public LlamaCppJniAiGenerationProvider(final LlamaCppJniConfig config, final AiPromptSupport promptSupport) {
this.config = Objects.requireNonNull(config, "config");
this.promptSupport = Objects.requireNonNull(promptSupport, "promptSupport");
- }
-
- /**
- * Builds the chat-template kwargs passed to
- * {@link net.ladenthin.llama.parameters.ModelParameters#setChatTemplateKwargs}.
- *
- *
Both kwargs are opt-in: an entry is written only when the user actually configured it, so a
- * chat template that has never heard of the kwarg is not handed it. That matters because
- * llama.cpp's Jinja layer has been moving unknown kwargs from "silently ignored" toward "warned
- * about" -- while {@code enable_thinking} was a plain {@code boolean} defaulting to {@code true},
- * every run sent it, and the only way to stop the noise was to set the knob to {@code false},
- * which means something else entirely.
- *
- * Package-private so it can be pinned without loading a GGUF; {@link #model()} is the only
- * production caller.
- *
- * @return the kwargs to send; empty when neither knob is configured
- */
- Map buildChatTemplateKwargs() {
- final Map chatTemplateKwargs =
- new HashMap<>(compatibilityHelper.hashMapCapacityFor(CHAT_TEMPLATE_KWARG_COUNT));
- // Qwen-style thinking. Unset (null) omits the kwarg so the model's own template default applies.
- final Boolean enableThinking = config.chatTemplateEnableThinking();
- if (enableThinking != null) {
- chatTemplateKwargs.put(ENABLE_THINKING_KWARG, String.valueOf(enableThinking.booleanValue()));
- }
- // gpt-oss honors reasoning_effort; non-gpt-oss chat templates ignore it. An empty
- // configured value omits the kwarg so the model's own template default applies.
- if (!compatibilityHelper.isBlank(config.reasoningEffort())) {
- chatTemplateKwargs.put(REASONING_EFFORT_KWARG, config.reasoningEffort());
- }
- return chatTemplateKwargs;
+ this.support = new LlamaCppJniProviderSupport(this.config, this.promptSupport);
}
/** Loads the GGUF model on first use and caches it for subsequent calls. */
private LlamaModel model() {
LlamaModel current = model;
if (current == null) {
- final Map chatTemplateKwargs = buildChatTemplateKwargs();
+ final Map chatTemplateKwargs = support.buildChatTemplateKwargs();
final ModelParameters modelParameters = new ModelParameters()
.setModel(config.modelPath())
.setCtxSize(config.contextSize())
@@ -170,7 +116,7 @@ private LlamaModel model() {
// load time, which matters here because a run loads one model per model group and
// calibrate preflights every model in turn. Empty leaves the binding/native-build default.
if (!compatibilityHelper.isBlank(config.lazyMode())) {
- modelParameters.setLazyMode(lazyMode(config.lazyMode()));
+ modelParameters.setLazyMode(LlamaCppJniProviderSupport.lazyMode(config.lazyMode()));
}
// Flash Attention. Emitted only when asked for: llama.cpp's own default is `auto`, so
// NOT emitting the option leaves it to decide per backend and model, which is what an
@@ -183,10 +129,10 @@ private LlamaModel model() {
// KV-cache quantization. Set independently of each other, but note that a quantized V cache
// generally needs Flash Attention above -- llama.cpp refuses the combination otherwise.
if (!compatibilityHelper.isBlank(config.cacheTypeK())) {
- modelParameters.setCacheTypeK(cacheType("cacheTypeK", config.cacheTypeK()));
+ modelParameters.setCacheTypeK(LlamaCppJniProviderSupport.cacheType("cacheTypeK", config.cacheTypeK()));
}
if (!compatibilityHelper.isBlank(config.cacheTypeV())) {
- modelParameters.setCacheTypeV(cacheType("cacheTypeV", config.cacheTypeV()));
+ modelParameters.setCacheTypeV(LlamaCppJniProviderSupport.cacheType("cacheTypeV", config.cacheTypeV()));
}
// Prefill sizing. The binding's own default for both is 0, which llama.cpp reads as
// "decide for me", so 0 is not a meaningful user value and the guard is > 0.
@@ -216,91 +162,11 @@ private LlamaModel model() {
return current;
}
- /**
- * Resolves a configured {@code --tensor-read-lazy} value to the binding's enum.
- *
- * Matched case-insensitively against the CLI strings the enum itself declares
- * ({@code off}, {@code auto}, {@code on}), so this never drifts from the binding. An
- * unrecognised value is rejected rather than silently ignored: it is always a configuration
- * typo, and dropping it would hand the user a run that quietly did not do what was asked.
- *
- * Package-private rather than private so it can be unit-tested without loading a model: it is
- * otherwise reachable only from the lazy {@code model()} path, which needs a real GGUF.
- *
- * @param value the configured value; must not be blank (callers guard on that)
- * @return the matching mode
- * @throws IllegalArgumentException if no mode declares that CLI string
- */
- static LazyMode lazyMode(final String value) {
- for (final LazyMode mode : LazyMode.values()) {
- if (mode.getArgValue().equalsIgnoreCase(value)) {
- return mode;
- }
- }
- throw new IllegalArgumentException(
- "Invalid lazyMode value: \"" + value + "\" (expected one of: " + knownLazyModeValues() + ")");
- }
-
- /**
- * Resolves a configured {@code --cache-type-k} / {@code --cache-type-v} value to the binding's enum.
- *
- * Same contract as {@link #lazyMode(String)}: matched case-insensitively against the CLI
- * strings the enum itself declares, so a cache type upstream adds is accepted for free, and an
- * unrecognised value is rejected rather than dropped. The knob name is passed in so the message names
- * the element the user actually wrote.
- *
- * @param knobName the configuration element name, for the error message
- * @param value the configured value; must not be blank (callers guard on that)
- * @return the matching cache type
- * @throws IllegalArgumentException if no cache type declares that CLI string
- */
- static CacheType cacheType(final String knobName, final String value) {
- for (final CacheType type : CacheType.values()) {
- if (type.getArgValue().equalsIgnoreCase(value)) {
- return type;
- }
- }
- throw new IllegalArgumentException(
- "Invalid " + knobName + " value: \"" + value + "\" (expected one of: " + knownCacheTypeValues() + ")");
- }
-
- /**
- * Renders the accepted KV-cache types for an error message.
- *
- * @return the CLI strings the binding's enum declares, comma-separated in declaration order
- */
- private static String knownCacheTypeValues() {
- final StringBuilder known = new StringBuilder();
- for (final CacheType type : CacheType.values()) {
- if (known.length() > 0) {
- known.append(", ");
- }
- known.append(type.getArgValue());
- }
- return known.toString();
- }
-
- /**
- * Renders the accepted {@code --tensor-read-lazy} values for an error message.
- *
- * @return the CLI strings the binding's enum declares, comma-separated in declaration order
- */
- private static String knownLazyModeValues() {
- final StringBuilder known = new StringBuilder();
- for (final LazyMode mode : LazyMode.values()) {
- if (known.length() > 0) {
- known.append(", ");
- }
- known.append(mode.getArgValue());
- }
- return known.toString();
- }
-
@Override
public String generate(final AiGenerationRequest request) throws IOException {
final ChatResponse response = chatResponse(request);
- warnOnTruncatedAnswer(request, response);
- logPromptCacheReuse(request, response);
+ support.warnOnTruncatedAnswer(request, response);
+ support.logPromptCacheReuse(request, response);
return completionParser.parseCompletion(response.getFirstContent());
}
@@ -316,73 +182,7 @@ public String generate(final AiGenerationRequest request) throws IOException {
* @return the parsed response
*/
private ChatResponse chatResponse(final AiGenerationRequest request) {
- return chatResponseParser.parseResponse(model().chatComplete(buildInferenceParameters(request)));
- }
-
- /**
- * Warns when the model stopped because it ran out of output budget rather than because it was done.
- *
- * {@code maxOutputTokens} defaults to 128. A summary that hits that ceiling is cut off mid-sentence
- * and written to the {@code .ai.md} exactly like a complete one — the run stays green and the
- * index silently degrades. The signal was already in the response this provider parses; it was simply
- * discarded along with the rest of it.
- *
- * Compared against the literal {@code "length"}, deliberately not against
- * {@code net.ladenthin.llama.value.StopReason}. Those are two different vocabularies:
- * {@code getFinishReason()} is OpenAI's ({@code stop} / {@code length} / {@code tool_calls}), while
- * {@code StopReason} maps llama.cpp's own {@code stop_type} ({@code eos} / {@code word} /
- * {@code limit}). {@code StopReason.fromStopType("length")} returns {@code NONE} — a silent
- * wrong answer, not a compile error.
- *
- * Package-private so the {@code "length"}-versus-{@code "stop"} decision can be driven from a
- * hand-built {@link ChatResponse}, with no model: the trap this method documents is a silent one,
- * so it needs a test that runs on every platform rather than only where a GGUF is present.
- *
- * @param request the request, for the file name in the message
- * @param response the parsed response
- */
- void warnOnTruncatedAnswer(final AiGenerationRequest request, final ChatResponse response) {
- if (response.getChoices().isEmpty()) {
- return;
- }
- if (!FINISH_REASON_LENGTH.equals(response.getChoices().get(0).getFinishReason())) {
- return;
- }
- LOGGER.warn(
- "Generated text for {} was cut off at the {}-token output budget, not finished by the model."
- + " The .ai.md will end mid-thought; raise maxOutputTokens for this model, or shorten"
- + " the prompt.",
- request.sourceFile(),
- config.maxOutputTokens());
- }
-
- /**
- * Logs, per generation, how much of the prompt the KV cache served.
- *
- * {@code srcmorph:calibrate} reports this for its own probe runs, but an indexing run — the
- * one that actually pays {@code swaFull}'s KV-memory surcharge, file after file — had no
- * visibility at all. At {@code DEBUG} because it is one line per file.
- *
- * Package-private for the same reason as {@link #warnOnTruncatedAnswer}: a hand-built
- * {@link ChatResponse} is enough to drive it.
- *
- * @param request the request, for the file name
- * @param response the parsed response
- */
- void logPromptCacheReuse(final AiGenerationRequest request, final ChatResponse response) {
- if (!LOGGER.isDebugEnabled()) {
- return;
- }
- // No null guard: ChatResponse.getUsage() is declared non-null, and SpotBugs flags a check on it
- // as dead code (RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE). Unlike getTimings(), which the
- // timings path does have to guard.
- final Usage usage = response.getUsage();
- LOGGER.debug(
- "{}: {} of {} prompt token(s) served from the KV cache, {} generated",
- request.sourceFile(),
- usage.getCachedTokens(),
- usage.getPromptTokens(),
- usage.getCompletionTokens());
+ return chatResponseParser.parseResponse(model().chatComplete(support.buildInferenceParameters(request)));
}
// Narrows away the interface's checked IOException: the chat path (chatComplete + parse) throws only
@@ -412,88 +212,6 @@ public AiGenerationTimings generateWithTimings(final AiGenerationRequest request
timings.getPredictedPerSecond());
}
- /**
- * Builds the immutable {@link InferenceParameters} for the given request from the resolved config.
- *
- * Package-private rather than private so the sentinel guards below can be tested without a
- * model: the method touches no llama state (the model is loaded lazily, on the first generate),
- * so a test can build a provider over a nonexistent path and inspect what would be sent. Given
- * that an unguarded negative window silently killed the provider for two releases, that guard
- * needs a test which does not depend on a GGUF being present.
- *
- * @param request the generation request
- * @return the inference parameters
- */
- InferenceParameters buildInferenceParameters(final AiGenerationRequest request) {
- // Static instructions go in the SYSTEM message (byte-identical across files, so its KV
- // prefix is reused by cache_prompt); the variable file name + source go in the USER message.
- final String systemPrompt = promptSupport.systemPrompt(request.promptKey());
- final String userMessage = promptSupport.userMessage(request.sourceFile(), request.sourceText());
-
- final List> messages = new ArrayList<>();
- messages.add(new Pair<>("user", userMessage));
-
- // InferenceParameters uses immutable withers: each with* returns a new instance, so the
- // whole request is built as a single chain.
- final InferenceParameters baseParameters = new InferenceParameters("")
- .withMessages(systemPrompt, messages)
- .withTemperature(config.temperature())
- .withNPredict(config.maxOutputTokens())
- .withTopP(config.topP())
- .withTopK(config.topK())
- .withMinP(config.minP())
- .withTopNSigma(config.topNSigma())
- .withRepeatPenalty(config.repeatPenalty())
- // Cap harmony analysis (reasoning) tokens so a runaway chain-of-thought cannot
- // starve the final answer; -1 (default) = unrestricted, so behaviour is unchanged.
- .withReasoningBudgetTokens(config.reasoningBudgetTokens())
- // DRY (Don't Repeat Yourself) repetition suppression; multiplier 0.0 (default) = off,
- // so the base/allowed-length knobs have no effect unless opted in. The DRY penalty
- // WINDOW is deliberately not in this chain -- see penaltyScopedParameters below.
- .withDryMultiplier(config.dryMultiplier())
- .withDryBase(config.dryBase())
- .withDryAllowedLength(config.dryAllowedLength())
- .withStopStrings(config.stopStrings().toArray(new String[0]))
- // Keep the shared prompt-template prefix warm in the KV cache and reuse it across
- // files (pinned to one slot); only the differing source is re-prefilled.
- // Reuse is exact -> output unchanged.
- .withCachePrompt(config.cachePrompt())
- .withSlotId(REUSE_SLOT_ID);
-
- // Pin the RNG seed only when explicitly configured (>= 0). Upstream's default is a random seed
- // per request, so an unconfigured run keeps exactly the behaviour it had; a configured one makes
- // the generated body stable for a given machine and configuration, which is what turns a
- // re-index into a reviewable diff. Not bit-reproducibility -- see AiGenerationConfig.DEFAULT_SEED.
- final InferenceParameters seededParameters =
- config.seed() >= 0 ? baseParameters.withSeed(config.seed()) : baseParameters;
-
- // The two penalty windows -- the one the repeat penalty acts on, and DRY's own -- are forwarded
- // only when configured (>= 0), so an unconfigured run keeps llama.cpp's own window; 0 is
- // meaningful (disables the penalty), which is why the guard is >= 0 rather than > 0.
- //
- // The guard is not an optimisation, it is the only thing keeping the provider alive. The binding
- // REJECTS a negative window outright (IllegalArgumentException, because llama.cpp b10273 dropped
- // "-1 = context size"), and both defaults are -1. An unguarded forward therefore kills EVERY
- // generation before a token is produced, whatever the rest of the configuration says. That is
- // not hypothetical: dryPenaltyLastN was forwarded unguarded and did exactly that in 1.1.0 and
- // 1.1.1. It survived because "DRY is off by default, so the window cannot matter" is true of the
- // window's *effect* and false of the setter's *validation* -- the wither rejects the value
- // whether or not DRY is active. Keep both guards, and add one for any future knob whose sentinel
- // is negative.
- final InferenceParameters repeatScopedParameters =
- config.repeatLastN() >= 0 ? seededParameters.withRepeatLastN(config.repeatLastN()) : seededParameters;
- final InferenceParameters penaltyScopedParameters = config.dryPenaltyLastN() >= 0
- ? repeatScopedParameters.withDryPenaltyLastN(config.dryPenaltyLastN())
- : repeatScopedParameters;
-
- // Only override the DRY sequence breakers when explicitly configured; an empty list keeps
- // the binding/model default set instead of clearing it.
- return config.drySequenceBreakers().isEmpty()
- ? penaltyScopedParameters
- : penaltyScopedParameters.withDrySequenceBreakers(
- config.drySequenceBreakers().toArray(new String[0]));
- }
-
@Override
public void close() {
final LlamaModel current = model;
diff --git a/srcmorph/src/main/java/net/ladenthin/srcmorph/provider/LlamaCppJniProviderSupport.java b/srcmorph/src/main/java/net/ladenthin/srcmorph/provider/LlamaCppJniProviderSupport.java
new file mode 100644
index 0000000..83e81ef
--- /dev/null
+++ b/srcmorph/src/main/java/net/ladenthin/srcmorph/provider/LlamaCppJniProviderSupport.java
@@ -0,0 +1,308 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: Apache-2.0
+package net.ladenthin.srcmorph.provider;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import lombok.ToString;
+import net.ladenthin.llama.args.CacheType;
+import net.ladenthin.llama.args.LazyMode;
+import net.ladenthin.llama.parameters.InferenceParameters;
+import net.ladenthin.llama.value.ChatResponse;
+import net.ladenthin.llama.value.Pair;
+import net.ladenthin.llama.value.Usage;
+import net.ladenthin.srcmorph.document.AiGenerationRequest;
+import net.ladenthin.srcmorph.prompt.AiPromptSupport;
+import net.ladenthin.srcmorph.support.Java8CompatibilityHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 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.
+ *
+ * Why this is a class of its own. 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.
+ *
+ * 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.
+ *
+ * Members are package-private: the provider is the only production caller, and the tests live in
+ * this package. {@code toString} is generated by Lombok for build-log diagnostics.
+ */
+@ToString
+final class LlamaCppJniProviderSupport {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(LlamaCppJniProviderSupport.class);
+
+ /** OpenAI finish reason meaning "the token budget ran out", as opposed to {@code "stop"}. */
+ private static final String FINISH_REASON_LENGTH = "length";
+
+ /**
+ * Fixed llama.cpp server slot every request is pinned to. The model is loaded once and reused
+ * for all files; pinning each request to the same slot lets the prompt cache
+ * ({@code cache_prompt}) reuse the shared prompt-template prefix's KV across files instead of
+ * re-prefilling it for every file. Reuse is exact, so generated output is unchanged.
+ */
+ private static final int REUSE_SLOT_ID = 0;
+
+ /** Chat-template kwarg controlling Qwen-style thinking (ignored by non-Qwen templates). */
+ private static final String ENABLE_THINKING_KWARG = "enable_thinking";
+
+ /** Chat-template kwarg for the gpt-oss reasoning-effort level (ignored by non-gpt-oss templates). */
+ private static final String REASONING_EFFORT_KWARG = "reasoning_effort";
+
+ /** Number of chat-template kwargs put into the map (used for presizing). */
+ private static final int CHAT_TEMPLATE_KWARG_COUNT = 2;
+
+ private final LlamaCppJniConfig config;
+ private final AiPromptSupport promptSupport;
+ private final Java8CompatibilityHelper compatibilityHelper = new Java8CompatibilityHelper();
+
+ /**
+ * Creates the support object for one provider instance.
+ *
+ * @param config llama.cpp configuration
+ * @param promptSupport prompt lookup used to render request prompts
+ */
+ LlamaCppJniProviderSupport(final LlamaCppJniConfig config, final AiPromptSupport promptSupport) {
+ this.config = Objects.requireNonNull(config, "config");
+ this.promptSupport = Objects.requireNonNull(promptSupport, "promptSupport");
+ }
+
+ /**
+ * Builds the chat-template kwargs passed to
+ * {@link net.ladenthin.llama.parameters.ModelParameters#setChatTemplateKwargs}.
+ *
+ * Both kwargs are opt-in: an entry is written only when the user actually configured it, so a
+ * chat template that has never heard of the kwarg is not handed it. That matters because
+ * llama.cpp's Jinja layer has been moving unknown kwargs from "silently ignored" toward "warned
+ * about" -- while {@code enable_thinking} was a plain {@code boolean} defaulting to {@code true},
+ * every run sent it, and the only way to stop the noise was to set the knob to {@code false},
+ * which means something else entirely.
+ *
+ * @return the kwargs to send; empty when neither knob is configured
+ */
+ Map buildChatTemplateKwargs() {
+ final Map chatTemplateKwargs =
+ new HashMap<>(compatibilityHelper.hashMapCapacityFor(CHAT_TEMPLATE_KWARG_COUNT));
+ // Qwen-style thinking. Unset (null) omits the kwarg so the model's own template default applies.
+ final Boolean enableThinking = config.chatTemplateEnableThinking();
+ if (enableThinking != null) {
+ chatTemplateKwargs.put(ENABLE_THINKING_KWARG, String.valueOf(enableThinking.booleanValue()));
+ }
+ // gpt-oss honors reasoning_effort; non-gpt-oss chat templates ignore it. An empty
+ // configured value omits the kwarg so the model's own template default applies.
+ if (!compatibilityHelper.isBlank(config.reasoningEffort())) {
+ chatTemplateKwargs.put(REASONING_EFFORT_KWARG, config.reasoningEffort());
+ }
+ return chatTemplateKwargs;
+ }
+
+ /**
+ * Resolves a configured {@code --lazy-mode} value to the binding's enum.
+ *
+ * Matched case-insensitively against the CLI strings the enum itself declares
+ * ({@code off}, {@code auto}, {@code on}), so this never drifts from the binding. An
+ * unrecognised value is rejected rather than silently ignored: it is always a configuration
+ * typo, and dropping it would hand the user a run that quietly did not do what was asked.
+ *
+ * @param value the configured value; must not be blank (callers guard on that)
+ * @return the matching mode
+ * @throws IllegalArgumentException if no mode declares that CLI string
+ */
+ static LazyMode lazyMode(final String value) {
+ for (final LazyMode mode : LazyMode.values()) {
+ if (mode.getArgValue().equalsIgnoreCase(value)) {
+ return mode;
+ }
+ }
+ throw new IllegalArgumentException(
+ "Invalid lazyMode value: \"" + value + "\" (expected one of: " + knownLazyModeValues() + ")");
+ }
+
+ /**
+ * Resolves a configured {@code --cache-type-k} / {@code --cache-type-v} value to the binding's enum.
+ *
+ * Same contract as {@link #lazyMode(String)}: matched case-insensitively against the CLI
+ * strings the enum itself declares, so a cache type upstream adds is accepted for free, and an
+ * unrecognised value is rejected rather than dropped. The knob name is passed in so the message names
+ * the element the user actually wrote.
+ *
+ * @param knobName the configuration element name, for the error message
+ * @param value the configured value; must not be blank (callers guard on that)
+ * @return the matching cache type
+ * @throws IllegalArgumentException if no cache type declares that CLI string
+ */
+ static CacheType cacheType(final String knobName, final String value) {
+ for (final CacheType type : CacheType.values()) {
+ if (type.getArgValue().equalsIgnoreCase(value)) {
+ return type;
+ }
+ }
+ throw new IllegalArgumentException(
+ "Invalid " + knobName + " value: \"" + value + "\" (expected one of: " + knownCacheTypeValues() + ")");
+ }
+
+ /**
+ * Renders the accepted KV-cache types for an error message.
+ *
+ * @return the CLI strings the binding's enum declares, comma-separated in declaration order
+ */
+ private static String knownCacheTypeValues() {
+ final StringBuilder known = new StringBuilder();
+ for (final CacheType type : CacheType.values()) {
+ if (known.length() > 0) {
+ known.append(", ");
+ }
+ known.append(type.getArgValue());
+ }
+ return known.toString();
+ }
+
+ /**
+ * Renders the accepted {@code --lazy-mode} values for an error message.
+ *
+ * @return the CLI strings the binding's enum declares, comma-separated in declaration order
+ */
+ private static String knownLazyModeValues() {
+ final StringBuilder known = new StringBuilder();
+ for (final LazyMode mode : LazyMode.values()) {
+ if (known.length() > 0) {
+ known.append(", ");
+ }
+ known.append(mode.getArgValue());
+ }
+ return known.toString();
+ }
+
+ /**
+ * Warns when the model stopped because the output budget ran out rather than because it was done.
+ *
+ * @param request the request whose source file is named in the warning
+ * @param response the completed chat response
+ */
+ void warnOnTruncatedAnswer(final AiGenerationRequest request, final ChatResponse response) {
+ if (response.getChoices().isEmpty()) {
+ return;
+ }
+ if (!FINISH_REASON_LENGTH.equals(response.getChoices().get(0).getFinishReason())) {
+ return;
+ }
+ LOGGER.warn(
+ "Generated text for {} was cut off at the {}-token output budget, not finished by the model."
+ + " The .ai.md will end mid-thought; raise maxOutputTokens for this model, or shorten"
+ + " the prompt.",
+ request.sourceFile(),
+ config.maxOutputTokens());
+ }
+
+ /**
+ * Reports how much of the prompt was served from the KV cache, at debug level.
+ *
+ * @param request the request whose source file is named in the line
+ * @param response the completed chat response
+ */
+ void logPromptCacheReuse(final AiGenerationRequest request, final ChatResponse response) {
+ if (!LOGGER.isDebugEnabled()) {
+ return;
+ }
+ // No null guard: ChatResponse.getUsage() is declared non-null, and SpotBugs flags a check on it
+ // as dead code (RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE). Unlike getTimings(), which the
+ // timings path does have to guard.
+ final Usage usage = response.getUsage();
+ LOGGER.debug(
+ "{}: {} of {} prompt token(s) served from the KV cache, {} generated",
+ request.sourceFile(),
+ usage.getCachedTokens(),
+ usage.getPromptTokens(),
+ usage.getCompletionTokens());
+ }
+
+ /**
+ * Builds the per-request inference parameters from the configuration and the request.
+ *
+ * @param request the request to render
+ * @return the parameters to hand to the binding
+ */
+ InferenceParameters buildInferenceParameters(final AiGenerationRequest request) {
+ // Static instructions go in the SYSTEM message (byte-identical across files, so its KV
+ // prefix is reused by cache_prompt); the variable file name + source go in the USER message.
+ final String systemPrompt = promptSupport.systemPrompt(request.promptKey());
+ final String userMessage = promptSupport.userMessage(request.sourceFile(), request.sourceText());
+ final List> messages = new ArrayList<>();
+ messages.add(new Pair<>("user", userMessage));
+ // InferenceParameters uses immutable withers: each with* returns a new instance, so the
+ // whole request is built as a single chain.
+ final InferenceParameters baseParameters = new InferenceParameters("")
+ .withMessages(systemPrompt, messages)
+ .withTemperature(config.temperature())
+ .withNPredict(config.maxOutputTokens())
+ .withTopP(config.topP())
+ .withTopK(config.topK())
+ .withMinP(config.minP())
+ .withTopNSigma(config.topNSigma())
+ .withRepeatPenalty(config.repeatPenalty())
+ // Cap harmony analysis (reasoning) tokens so a runaway chain-of-thought cannot
+ // starve the final answer; -1 (default) = unrestricted, so behaviour is unchanged.
+ .withReasoningBudgetTokens(config.reasoningBudgetTokens())
+ // DRY (Don't Repeat Yourself) repetition suppression; multiplier 0.0 (default) = off,
+ // so the base/allowed-length knobs have no effect unless opted in. The DRY penalty
+ // WINDOW is deliberately not in this chain -- see penaltyScopedParameters below.
+ .withDryMultiplier(config.dryMultiplier())
+ .withDryBase(config.dryBase())
+ .withDryAllowedLength(config.dryAllowedLength())
+ .withStopStrings(config.stopStrings().toArray(new String[0]))
+ // Keep the shared prompt-template prefix warm in the KV cache and reuse it across
+ // files (pinned to one slot); only the differing source is re-prefilled.
+ // Reuse is exact -> output unchanged.
+ .withCachePrompt(config.cachePrompt())
+ .withSlotId(REUSE_SLOT_ID);
+ // Pin the RNG seed only when explicitly configured (>= 0). Upstream's default is a random seed
+ // per request, so an unconfigured run keeps exactly the behaviour it had; a configured one makes
+ // the generated body stable for a given machine and configuration, which is what turns a
+ // re-index into a reviewable diff. Not bit-reproducibility -- see AiGenerationConfig.DEFAULT_SEED.
+ final InferenceParameters seededParameters =
+ config.seed() >= 0 ? baseParameters.withSeed(config.seed()) : baseParameters;
+ // The two penalty windows -- the one the repeat penalty acts on, and DRY's own -- are forwarded
+ // only when configured (>= 0), so an unconfigured run keeps llama.cpp's own window; 0 is
+ // meaningful (disables the penalty), which is why the guard is >= 0 rather than > 0.
+ //
+ // The guard is not an optimisation, it is the only thing keeping the provider alive. The binding
+ // REJECTS a negative window outright (IllegalArgumentException, because llama.cpp b10273 dropped
+ // "-1 = context size"), and both defaults are -1. An unguarded forward therefore kills EVERY
+ // generation before a token is produced, whatever the rest of the configuration says. That is
+ // not hypothetical: dryPenaltyLastN was forwarded unguarded and did exactly that in 1.1.0 and
+ // 1.1.1. It survived because "DRY is off by default, so the window cannot matter" is true of the
+ // window's *effect* and false of the setter's *validation* -- the wither rejects the value
+ // whether or not DRY is active. Keep both guards, and add one for any future knob whose sentinel
+ // is negative.
+ final InferenceParameters repeatScopedParameters =
+ config.repeatLastN() >= 0 ? seededParameters.withRepeatLastN(config.repeatLastN()) : seededParameters;
+ final InferenceParameters penaltyScopedParameters = config.dryPenaltyLastN() >= 0
+ ? repeatScopedParameters.withDryPenaltyLastN(config.dryPenaltyLastN())
+ : repeatScopedParameters;
+ // Only override the DRY sequence breakers when explicitly configured; an empty list keeps
+ // the binding/model default set instead of clearing it.
+ return config.drySequenceBreakers().isEmpty()
+ ? penaltyScopedParameters
+ : penaltyScopedParameters.withDrySequenceBreakers(
+ config.drySequenceBreakers().toArray(new String[0]));
+ }
+}
diff --git a/srcmorph/src/test/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProviderTest.java b/srcmorph/src/test/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProviderTest.java
index d1c9c5e..dd7635e 100644
--- a/srcmorph/src/test/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProviderTest.java
+++ b/srcmorph/src/test/java/net/ladenthin/srcmorph/provider/LlamaCppJniAiGenerationProviderTest.java
@@ -3,34 +3,18 @@
// SPDX-License-Identifier: Apache-2.0
package net.ladenthin.srcmorph.provider;
-import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
-import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
-import ch.qos.logback.classic.Level;
-import ch.qos.logback.classic.Logger;
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import ch.qos.logback.core.read.ListAppender;
import java.nio.file.Paths;
-import java.util.Collections;
-import java.util.Map;
-import net.ladenthin.llama.args.CacheType;
-import net.ladenthin.llama.args.LazyMode;
-import net.ladenthin.llama.value.ChatChoice;
-import net.ladenthin.llama.value.ChatMessage;
-import net.ladenthin.llama.value.ChatResponse;
-import net.ladenthin.llama.value.Usage;
import net.ladenthin.srcmorph.CommonTestFixtures;
import net.ladenthin.srcmorph.NativeLlamaAvailability;
import net.ladenthin.srcmorph.document.AiGenerationRequest;
import net.ladenthin.srcmorph.document.AiMdHeader;
import net.ladenthin.srcmorph.document.AiMdHeaderCodec;
import net.ladenthin.srcmorph.prompt.AiPromptSupport;
-import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
-import org.slf4j.LoggerFactory;
public class LlamaCppJniAiGenerationProviderTest {
@@ -132,375 +116,4 @@ public void generateWithTimings_realProvider_reportsEngineTimingsThatScaleWithPr
}
}
//
-
- @Test
- public void lazyMode_mapsEveryDeclaredCliString() {
- // Every mode the binding declares must resolve; a new upstream mode is then covered for free.
- for (final LazyMode mode : LazyMode.values()) {
- assertThat(LlamaCppJniAiGenerationProvider.lazyMode(mode.getArgValue()), is(mode));
- }
- }
-
- @Test
- public void lazyMode_isCaseInsensitive() {
- assertThat(LlamaCppJniAiGenerationProvider.lazyMode("ON"), is(LazyMode.ON));
- assertThat(LlamaCppJniAiGenerationProvider.lazyMode("Auto"), is(LazyMode.AUTO));
- }
-
- @Test
- public void lazyMode_rejectsUnknownValueAndNamesTheAcceptedOnes() {
- // A typo must fail loud rather than be dropped, and the message must name what is accepted.
- final IllegalArgumentException thrown = Assertions.assertThrows(
- IllegalArgumentException.class, () -> LlamaCppJniAiGenerationProvider.lazyMode("lazy"));
- assertThat(thrown.getMessage(), containsString("lazy"));
- assertThat(thrown.getMessage(), containsString("off, auto, on"));
- }
-
- @Test
- public void cacheType_mapsEveryDeclaredCliString() {
- // Every cache type the binding declares must resolve; a type upstream adds is covered for free.
- for (final CacheType type : CacheType.values()) {
- assertThat(LlamaCppJniAiGenerationProvider.cacheType("cacheTypeK", type.getArgValue()), is(type));
- }
- }
-
- @Test
- public void cacheType_isCaseInsensitive() {
- assertThat(LlamaCppJniAiGenerationProvider.cacheType("cacheTypeK", "Q8_0"), is(CacheType.Q8_0));
- assertThat(LlamaCppJniAiGenerationProvider.cacheType("cacheTypeV", "F16"), is(CacheType.F16));
- }
-
- @Test
- public void cacheType_rejectsUnknownValueAndNamesBothTheKnobAndTheAcceptedOnes() {
- // A typo must fail loud rather than be dropped. The knob name is in the message because the same
- // resolver serves cacheTypeK and cacheTypeV -- without it the user cannot tell which one is wrong.
- final IllegalArgumentException thrown = Assertions.assertThrows(
- IllegalArgumentException.class, () -> LlamaCppJniAiGenerationProvider.cacheType("cacheTypeV", "q3_k"));
- assertThat(thrown.getMessage(), containsString("cacheTypeV"));
- assertThat(thrown.getMessage(), containsString("q3_k"));
- assertThat(thrown.getMessage(), containsString("f32, f16, bf16, q8_0"));
- }
-
- //
-
- /**
- * Builds a provider over a path that does not exist. The model is loaded lazily on the first
- * generate, and {@code buildInferenceParameters} never touches it, so these tests need no GGUF and
- * no native library -- which is the point: the regression they guard shipped twice while every
- * model-free gate stayed green.
- */
- private static LlamaCppJniAiGenerationProvider providerWith(final LlamaCppJniConfig config) {
- return new LlamaCppJniAiGenerationProvider(
- config, new AiPromptSupport(CommonTestFixtures.createFilePromptDefinitions()));
- }
-
- /**
- * 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 {}")));
- }
-
- /** A sentinel means "say nothing", so llama.cpp keeps its own window -- it must not be sent as -1. */
- @Test
- public void buildInferenceParameters_defaultConfig_sendsNeitherPenaltyWindow() {
- // arrange
- final LlamaCppJniConfig defaults =
- LlamaCppJniConfig.builder("/does/not/exist.gguf").build();
-
- // act
- final String json = providerWith(defaults)
- .buildInferenceParameters(request("class A {}"))
- .toString();
-
- // assert
- assertThat(json, not(containsString(PARAM_DRY_PENALTY_LAST_N)));
- assertThat(json, not(containsString(PARAM_REPEAT_LAST_N)));
- }
-
- /**
- * {@code 0} is a real value, not a second sentinel: it disables the penalty. A guard written as
- * {@code > 0} would swallow it, so this pins the boundary from the other side.
- */
- @Test
- public void buildInferenceParameters_zeroWindows_sendsBothAsZero() {
- // arrange
- final LlamaCppJniConfig zeroed = LlamaCppJniConfig.builder("/does/not/exist.gguf")
- .dryPenaltyLastN(0)
- .repeatLastN(0)
- .build();
-
- // act
- final String json = providerWith(zeroed)
- .buildInferenceParameters(request("class A {}"))
- .toString();
-
- // assert
- assertThat(json, containsString(PARAM_DRY_PENALTY_LAST_N));
- assertThat(json, containsString(PARAM_REPEAT_LAST_N));
- }
-
- /** A configured positive window reaches the request unchanged. */
- @Test
- public void buildInferenceParameters_configuredWindows_arePassedThrough() {
- // arrange
- final LlamaCppJniConfig configured = LlamaCppJniConfig.builder("/does/not/exist.gguf")
- .dryPenaltyLastN(64)
- .repeatLastN(128)
- .build();
-
- // act
- final String json = providerWith(configured)
- .buildInferenceParameters(request("class A {}"))
- .toString();
-
- // assert -- distinct values, so a transposition of the two guards fails rather than cancelling out
- assertThat(json, containsString(PARAM_DRY_PENALTY_LAST_N + ": 64"));
- assertThat(json, containsString(PARAM_REPEAT_LAST_N + ": 128"));
- }
-
- //
-
- //
-
- /** Builds a response carrying just the one field each of these two helpers reads. */
- private static ChatResponse responseWith(final String finishReason, final Usage usage) {
- final ChatChoice choice = new ChatChoice(0, new ChatMessage("assistant", "body"), finishReason);
- return new ChatResponse("id", Collections.singletonList(choice), usage, null, "{}");
- }
-
- private static ListAppender attachAppender(final Level level) {
- final ListAppender appender = new ListAppender<>();
- appender.start();
- final Logger logger = (Logger) LoggerFactory.getLogger(LlamaCppJniAiGenerationProvider.class);
- logger.setLevel(level);
- logger.addAppender(appender);
- return appender;
- }
-
- private static void detachAppender(final ListAppender appender) {
- ((Logger) LoggerFactory.getLogger(LlamaCppJniAiGenerationProvider.class)).detachAppender(appender);
- appender.stop();
- }
-
- /**
- * The whole point of the feature: {@code length} means the model hit the output budget and stopped
- * mid-sentence, so the {@code .ai.md} it produced is incomplete and the user has to be told.
- */
- @Test
- public void warnOnTruncatedAnswer_finishReasonLength_warnsAndNamesTheFileAndTheBudget() {
- // arrange
- final ListAppender appender = attachAppender(Level.WARN);
- try {
- final LlamaCppJniAiGenerationProvider provider =
- providerWith(LlamaCppJniConfig.builder("/does/not/exist.gguf")
- .maxOutputTokens(128)
- .build());
-
- // act
- provider.warnOnTruncatedAnswer(request("class A {}"), responseWith("length", new Usage(1, 1)));
-
- // assert
- assertThat(appender.list.size(), is(1));
- final String message = appender.list.get(0).getFormattedMessage();
- assertThat(message, containsString("Test.java"));
- assertThat(message, containsString("128"));
- } finally {
- detachAppender(appender);
- }
- }
-
- /**
- * A normal completion must stay silent. This is the assertion that pins the {@code "length"}
- * literal: {@code StopReason.fromStopType("length")} is {@code NONE}, so comparing against the
- * wrong vocabulary would make the warning fire never or always -- silently, either way.
- */
- @Test
- public void warnOnTruncatedAnswer_finishReasonStop_saysNothing() {
- // arrange
- final ListAppender appender = attachAppender(Level.WARN);
- try {
- final LlamaCppJniAiGenerationProvider provider = providerWith(
- LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
-
- // act
- provider.warnOnTruncatedAnswer(request("class A {}"), responseWith("stop", new Usage(1, 1)));
-
- // assert
- assertThat(appender.list.isEmpty(), is(true));
- } finally {
- detachAppender(appender);
- }
- }
-
- /** No choices means nothing to judge; reading {@code get(0)} anyway would throw. */
- @Test
- public void warnOnTruncatedAnswer_noChoices_saysNothing() {
- // arrange
- final ListAppender appender = attachAppender(Level.WARN);
- try {
- final LlamaCppJniAiGenerationProvider provider = providerWith(
- LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
- final ChatResponse empty = new ChatResponse("id", Collections.emptyList(), new Usage(1, 1), null, "{}");
-
- // act
- provider.warnOnTruncatedAnswer(request("class A {}"), empty);
-
- // assert
- assertThat(appender.list.isEmpty(), is(true));
- } finally {
- detachAppender(appender);
- }
- }
-
- /** The cache line reports all three counts, so a transposition of two of them is visible. */
- @Test
- public void logPromptCacheReuse_debugEnabled_reportsCachedTotalAndGenerated() {
- // arrange
- final ListAppender appender = attachAppender(Level.DEBUG);
- try {
- final LlamaCppJniAiGenerationProvider provider = providerWith(
- LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
-
- // act -- distinct values so each lands in its own placeholder
- provider.logPromptCacheReuse(request("class A {}"), responseWith("stop", new Usage(70, 11, 33)));
-
- // assert
- assertThat(appender.list.size(), is(1));
- final String message = appender.list.get(0).getFormattedMessage();
- assertThat(message, containsString("33 of 70 prompt token(s)"));
- assertThat(message, containsString("11 generated"));
- } finally {
- detachAppender(appender);
- }
- }
-
- /** At INFO the line must not be built at all -- it is one log line per indexed file. */
- @Test
- public void logPromptCacheReuse_debugDisabled_saysNothing() {
- // arrange
- final ListAppender appender = attachAppender(Level.INFO);
- try {
- final LlamaCppJniAiGenerationProvider provider = providerWith(
- LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
-
- // act
- provider.logPromptCacheReuse(request("class A {}"), responseWith("stop", new Usage(70, 11, 33)));
-
- // assert
- assertThat(appender.list.isEmpty(), is(true));
- } finally {
- detachAppender(appender);
- }
- }
-
- //
-
- /**
- * The point of the tri-state. While {@code chatTemplateEnableThinking} was a plain
- * {@code boolean} defaulting to {@code true}, every run handed {@code enable_thinking} to the
- * chat template -- including templates that have never heard of it, which llama.cpp's Jinja
- * layer has been moving from "silently ignored" toward "warned about". Unset must now mean
- * "say nothing", so the template's own default applies.
- *
- * The sibling kwarg is deliberately not asserted away here: {@code reasoningEffort} does
- * default to {@code "low"} and is therefore still sent by a default run, but unlike
- * {@code enable_thinking} it has an escape that means exactly "unset" -- the empty string -- so a
- * non-gpt-oss user can switch it off without picking a value that says something else. That is a
- * documented choice, not the same defect; the next test pins that escape.
- */
- @Test
- public void buildChatTemplateKwargs_defaultConfig_doesNotSendEnableThinking() {
- // arrange
- final LlamaCppJniConfig defaults =
- LlamaCppJniConfig.builder("/does/not/exist.gguf").build();
-
- // act
- final Map kwargs = providerWith(defaults).buildChatTemplateKwargs();
-
- // assert
- assertThat(kwargs.containsKey(KWARG_ENABLE_THINKING), is(false));
- }
-
- /** The other half of "unset means unset": a blank reasoning effort omits its kwarg too. */
- @Test
- public void buildChatTemplateKwargs_blankReasoningEffort_sendsNoKwargAtAll() {
- // arrange
- final LlamaCppJniConfig blank = LlamaCppJniConfig.builder("/does/not/exist.gguf")
- .reasoningEffort("")
- .build();
-
- // act
- final Map kwargs = providerWith(blank).buildChatTemplateKwargs();
-
- // assert
- assertThat(kwargs.isEmpty(), is(true));
- }
-
- /**
- * {@code true} is a real configured value, not a second spelling of "unset". A guard written as
- * "send it only when false" would swallow it, so this pins the boundary from the other side --
- * the same shape as the {@code 0}-versus-{@code -1} penalty-window pair above.
- */
- @Test
- public void buildChatTemplateKwargs_thinkingSetToTrue_isStillSent() {
- // arrange
- final LlamaCppJniConfig enabled = LlamaCppJniConfig.builder("/does/not/exist.gguf")
- .chatTemplateEnableThinking(Boolean.TRUE)
- .build();
-
- // act
- final Map kwargs = providerWith(enabled).buildChatTemplateKwargs();
-
- // assert
- assertThat(kwargs.get(KWARG_ENABLE_THINKING), is("true"));
- }
-
- /** The Gemma-4 case the knob exists for: suppress the thinking block at the Jinja level. */
- @Test
- public void buildChatTemplateKwargs_thinkingSetToFalse_isSentAsFalse() {
- // arrange
- final LlamaCppJniConfig disabled = LlamaCppJniConfig.builder("/does/not/exist.gguf")
- .chatTemplateEnableThinking(Boolean.FALSE)
- .build();
-
- // act
- final Map kwargs = providerWith(disabled).buildChatTemplateKwargs();
-
- // assert
- assertThat(kwargs.get(KWARG_ENABLE_THINKING), is("false"));
- }
-
- /**
- * The second kwarg was already opt-in (blank omits it); pinned here so the extraction of
- * {@code buildChatTemplateKwargs} out of {@code model()} cannot drop it unnoticed.
- */
- @Test
- public void buildChatTemplateKwargs_reasoningEffortConfigured_isSentAlongside() {
- // arrange
- final LlamaCppJniConfig configured = LlamaCppJniConfig.builder("/does/not/exist.gguf")
- .reasoningEffort("high")
- .chatTemplateEnableThinking(Boolean.FALSE)
- .build();
-
- // act
- final Map kwargs = providerWith(configured).buildChatTemplateKwargs();
-
- // assert
- assertThat(kwargs.get(KWARG_REASONING_EFFORT), is("high"));
- assertThat(kwargs.get(KWARG_ENABLE_THINKING), is("false"));
- }
-
- //
-
- //
}
diff --git a/srcmorph/src/test/java/net/ladenthin/srcmorph/provider/LlamaCppJniProviderSupportTest.java b/srcmorph/src/test/java/net/ladenthin/srcmorph/provider/LlamaCppJniProviderSupportTest.java
new file mode 100644
index 0000000..981fafb
--- /dev/null
+++ b/srcmorph/src/test/java/net/ladenthin/srcmorph/provider/LlamaCppJniProviderSupportTest.java
@@ -0,0 +1,530 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: Apache-2.0
+package net.ladenthin.srcmorph.provider;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.Map;
+import net.ladenthin.llama.args.CacheType;
+import net.ladenthin.llama.args.LazyMode;
+import net.ladenthin.llama.value.ChatChoice;
+import net.ladenthin.llama.value.ChatMessage;
+import net.ladenthin.llama.value.ChatResponse;
+import net.ladenthin.llama.value.Usage;
+import net.ladenthin.srcmorph.CommonTestFixtures;
+import net.ladenthin.srcmorph.document.AiGenerationRequest;
+import net.ladenthin.srcmorph.document.AiMdHeader;
+import net.ladenthin.srcmorph.document.AiMdHeaderCodec;
+import net.ladenthin.srcmorph.prompt.AiPromptSupport;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Model-free tests for {@link LlamaCppJniProviderSupport}.
+ *
+ * Every case here builds its configuration over a path that does not exist. That is not a
+ * shortcut, it is the contract this class exists to keep: none of the support methods touches the
+ * native handle, so all of them can be pinned without a GGUF -- which is what lets the mutation
+ * gate cover them at threshold 100 without dragging the model-loading suites into every mutant.
+ */
+public class LlamaCppJniProviderSupportTest {
+
+ /**
+ * JSON key of the DRY penalty window as the binding renders it. Quoted so the assertions cannot
+ * accidentally match a different key that merely contains this one as a substring.
+ */
+ private static final String PARAM_DRY_PENALTY_LAST_N = "\"dry_penalty_last_n\"";
+
+ /** JSON key of the repeat-penalty window, quoted for the same reason. */
+ private static final String PARAM_REPEAT_LAST_N = "\"repeat_last_n\"";
+
+ /** JSON key of the RNG seed, quoted for the same reason. */
+ private static final String PARAM_SEED = "\"seed\"";
+
+ /** JSON key of the DRY sequence breakers, quoted for the same reason. */
+ private static final String PARAM_DRY_SEQUENCE_BREAKERS = "\"dry_sequence_breakers\"";
+
+ /** Chat-template kwarg key for Qwen-style thinking, as the provider spells it. */
+ private static final String KWARG_ENABLE_THINKING = "enable_thinking";
+
+ /** Chat-template kwarg key for the gpt-oss reasoning-effort level. */
+ private static final String KWARG_REASONING_EFFORT = "reasoning_effort";
+
+ private static final AiMdHeader HEADER = new AiMdHeader(
+ "Test.java",
+ AiMdHeaderCodec.HEADER_VERSION_1_0,
+ "00000000",
+ "2026-03-18T00:00:00Z",
+ "2026-03-18T00:00:00Z",
+ "0.1.0-SNAPSHOT",
+ "0.0.0",
+ AiMdHeaderCodec.NODE_TYPE_FILE);
+
+ private static AiGenerationRequest request(final String source) {
+ return new AiGenerationRequest(CommonTestFixtures.PROMPT_KEY_FILE_BODY, Paths.get("Test.java"), source, HEADER);
+ }
+
+ @Test
+ public void lazyMode_mapsEveryDeclaredCliString() {
+ // Every mode the binding declares must resolve; a new upstream mode is then covered for free.
+ for (final LazyMode mode : LazyMode.values()) {
+ assertThat(LlamaCppJniProviderSupport.lazyMode(mode.getArgValue()), is(mode));
+ }
+ }
+
+ @Test
+ public void lazyMode_isCaseInsensitive() {
+ assertThat(LlamaCppJniProviderSupport.lazyMode("ON"), is(LazyMode.ON));
+ assertThat(LlamaCppJniProviderSupport.lazyMode("Auto"), is(LazyMode.AUTO));
+ }
+
+ @Test
+ public void lazyMode_rejectsUnknownValueAndNamesTheAcceptedOnes() {
+ // A typo must fail loud rather than be dropped, and the message must name what is accepted.
+ final IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class, () -> LlamaCppJniProviderSupport.lazyMode("lazy"));
+ assertThat(thrown.getMessage(), containsString("lazy"));
+ assertThat(thrown.getMessage(), containsString("off, auto, on"));
+ // Anchored to the "expected one of: " prefix on purpose: the renderer emits its separator
+ // only from the second value on, and a guard written as >= 0 instead of > 0 would prepend a
+ // stray ", " that a containsString on the middle of the list cannot see.
+ assertThat(thrown.getMessage(), containsString("expected one of: off"));
+ }
+
+ @Test
+ public void cacheType_mapsEveryDeclaredCliString() {
+ // Every cache type the binding declares must resolve; a type upstream adds is covered for free.
+ for (final CacheType type : CacheType.values()) {
+ assertThat(LlamaCppJniProviderSupport.cacheType("cacheTypeK", type.getArgValue()), is(type));
+ }
+ }
+
+ @Test
+ public void cacheType_isCaseInsensitive() {
+ assertThat(LlamaCppJniProviderSupport.cacheType("cacheTypeK", "Q8_0"), is(CacheType.Q8_0));
+ assertThat(LlamaCppJniProviderSupport.cacheType("cacheTypeV", "F16"), is(CacheType.F16));
+ }
+
+ @Test
+ public void cacheType_rejectsUnknownValueAndNamesBothTheKnobAndTheAcceptedOnes() {
+ // A typo must fail loud rather than be dropped. The knob name is in the message because the same
+ // resolver serves cacheTypeK and cacheTypeV -- without it the user cannot tell which one is wrong.
+ final IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class, () -> LlamaCppJniProviderSupport.cacheType("cacheTypeV", "q3_k"));
+ assertThat(thrown.getMessage(), containsString("cacheTypeV"));
+ assertThat(thrown.getMessage(), containsString("q3_k"));
+ assertThat(thrown.getMessage(), containsString("f32, f16, bf16, q8_0"));
+ // Same anchor as lazyMode above, for the same separator boundary.
+ assertThat(thrown.getMessage(), containsString("expected one of: f32"));
+ }
+
+ //
+
+ /**
+ * Builds a provider over a path that does not exist. The model is loaded lazily on the first
+ * generate, and {@code buildInferenceParameters} never touches it, so these tests need no GGUF and
+ * no native library -- which is the point: the regression they guard shipped twice while every
+ * model-free gate stayed green.
+ */
+ private static LlamaCppJniProviderSupport providerWith(final LlamaCppJniConfig config) {
+ return new LlamaCppJniProviderSupport(
+ config, new AiPromptSupport(CommonTestFixtures.createFilePromptDefinitions()));
+ }
+
+ /**
+ * 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 {}")));
+ }
+
+ /** A sentinel means "say nothing", so llama.cpp keeps its own window -- it must not be sent as -1. */
+ @Test
+ public void buildInferenceParameters_defaultConfig_sendsNeitherPenaltyWindow() {
+ // arrange
+ final LlamaCppJniConfig defaults =
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build();
+
+ // act
+ final String json = providerWith(defaults)
+ .buildInferenceParameters(request("class A {}"))
+ .toString();
+
+ // assert
+ assertThat(json, not(containsString(PARAM_DRY_PENALTY_LAST_N)));
+ assertThat(json, not(containsString(PARAM_REPEAT_LAST_N)));
+ }
+
+ /**
+ * {@code 0} is a real value, not a second sentinel: it disables the penalty. A guard written as
+ * {@code > 0} would swallow it, so this pins the boundary from the other side.
+ */
+ @Test
+ public void buildInferenceParameters_zeroWindows_sendsBothAsZero() {
+ // arrange
+ final LlamaCppJniConfig zeroed = LlamaCppJniConfig.builder("/does/not/exist.gguf")
+ .dryPenaltyLastN(0)
+ .repeatLastN(0)
+ .build();
+
+ // act
+ final String json = providerWith(zeroed)
+ .buildInferenceParameters(request("class A {}"))
+ .toString();
+
+ // assert
+ assertThat(json, containsString(PARAM_DRY_PENALTY_LAST_N));
+ assertThat(json, containsString(PARAM_REPEAT_LAST_N));
+ }
+
+ /** A configured positive window reaches the request unchanged. */
+ @Test
+ public void buildInferenceParameters_configuredWindows_arePassedThrough() {
+ // arrange
+ final LlamaCppJniConfig configured = LlamaCppJniConfig.builder("/does/not/exist.gguf")
+ .dryPenaltyLastN(64)
+ .repeatLastN(128)
+ .build();
+
+ // act
+ final String json = providerWith(configured)
+ .buildInferenceParameters(request("class A {}"))
+ .toString();
+
+ // assert -- distinct values, so a transposition of the two guards fails rather than cancelling out
+ assertThat(json, containsString(PARAM_DRY_PENALTY_LAST_N + ": 64"));
+ assertThat(json, containsString(PARAM_REPEAT_LAST_N + ": 128"));
+ }
+
+ /**
+ * The seed sentinel is {@link net.ladenthin.srcmorph.config.AiGenerationConfig#DEFAULT_SEED} (-1),
+ * meaning "random per request". Forwarding it would pin every run to seed -1 and silently destroy
+ * the randomness upstream provides, so an unconfigured run must send nothing at all.
+ */
+ @Test
+ public void buildInferenceParameters_defaultConfig_sendsNoSeed() {
+ // arrange
+ final LlamaCppJniConfig defaults =
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build();
+
+ // act
+ final String json = providerWith(defaults)
+ .buildInferenceParameters(request("class A {}"))
+ .toString();
+
+ // assert
+ assertThat(json, not(containsString(PARAM_SEED)));
+ }
+
+ /**
+ * {@code 0} is a legitimate seed, not a second sentinel, so the guard has to be {@code >= 0}. Pins
+ * that boundary from the other side, exactly as the penalty windows do above -- a guard written as
+ * {@code > 0} would silently drop a run the user pinned to seed 0.
+ */
+ @Test
+ public void buildInferenceParameters_zeroSeed_isStillSent() {
+ // arrange
+ final LlamaCppJniConfig zeroSeed =
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").seed(0).build();
+
+ // act
+ final String json = providerWith(zeroSeed)
+ .buildInferenceParameters(request("class A {}"))
+ .toString();
+
+ // assert
+ assertThat(json, containsString(PARAM_SEED + ": 0"));
+ }
+
+ /**
+ * An empty breaker list means "keep the binding's own default set", not "clear it". Sending an empty
+ * array would replace a sensible default with nothing, which is a behaviour change rather than a
+ * no-op -- hence the guard, and hence this test.
+ */
+ @Test
+ public void buildInferenceParameters_defaultConfig_sendsNoDrySequenceBreakers() {
+ // arrange
+ final LlamaCppJniConfig defaults =
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build();
+
+ // act
+ final String json = providerWith(defaults)
+ .buildInferenceParameters(request("class A {}"))
+ .toString();
+
+ // assert
+ assertThat(json, not(containsString(PARAM_DRY_SEQUENCE_BREAKERS)));
+ }
+
+ /** A configured breaker list reaches the request. */
+ @Test
+ 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));
+ }
+
+ //
+
+ //
+
+ /** Builds a response carrying just the one field each of these two helpers reads. */
+ private static ChatResponse responseWith(final String finishReason, final Usage usage) {
+ final ChatChoice choice = new ChatChoice(0, new ChatMessage("assistant", "body"), finishReason);
+ return new ChatResponse("id", Collections.singletonList(choice), usage, null, "{}");
+ }
+
+ private static ListAppender attachAppender(final Level level) {
+ final ListAppender appender = new ListAppender<>();
+ appender.start();
+ final Logger logger = (Logger) LoggerFactory.getLogger(LlamaCppJniProviderSupport.class);
+ logger.setLevel(level);
+ logger.addAppender(appender);
+ return appender;
+ }
+
+ private static void detachAppender(final ListAppender appender) {
+ ((Logger) LoggerFactory.getLogger(LlamaCppJniProviderSupport.class)).detachAppender(appender);
+ appender.stop();
+ }
+
+ /**
+ * The whole point of the feature: {@code length} means the model hit the output budget and stopped
+ * mid-sentence, so the {@code .ai.md} it produced is incomplete and the user has to be told.
+ */
+ @Test
+ public void warnOnTruncatedAnswer_finishReasonLength_warnsAndNamesTheFileAndTheBudget() {
+ // arrange
+ final ListAppender appender = attachAppender(Level.WARN);
+ try {
+ final LlamaCppJniProviderSupport provider = providerWith(LlamaCppJniConfig.builder("/does/not/exist.gguf")
+ .maxOutputTokens(128)
+ .build());
+
+ // act
+ provider.warnOnTruncatedAnswer(request("class A {}"), responseWith("length", new Usage(1, 1)));
+
+ // assert
+ assertThat(appender.list.size(), is(1));
+ final String message = appender.list.get(0).getFormattedMessage();
+ assertThat(message, containsString("Test.java"));
+ assertThat(message, containsString("128"));
+ } finally {
+ detachAppender(appender);
+ }
+ }
+
+ /**
+ * A normal completion must stay silent. This is the assertion that pins the {@code "length"}
+ * literal: {@code StopReason.fromStopType("length")} is {@code NONE}, so comparing against the
+ * wrong vocabulary would make the warning fire never or always -- silently, either way.
+ */
+ @Test
+ public void warnOnTruncatedAnswer_finishReasonStop_saysNothing() {
+ // arrange
+ final ListAppender appender = attachAppender(Level.WARN);
+ try {
+ final LlamaCppJniProviderSupport provider = providerWith(
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
+
+ // act
+ provider.warnOnTruncatedAnswer(request("class A {}"), responseWith("stop", new Usage(1, 1)));
+
+ // assert
+ assertThat(appender.list.isEmpty(), is(true));
+ } finally {
+ detachAppender(appender);
+ }
+ }
+
+ /** No choices means nothing to judge; reading {@code get(0)} anyway would throw. */
+ @Test
+ public void warnOnTruncatedAnswer_noChoices_saysNothing() {
+ // arrange
+ final ListAppender appender = attachAppender(Level.WARN);
+ try {
+ final LlamaCppJniProviderSupport provider = providerWith(
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
+ final ChatResponse empty = new ChatResponse("id", Collections.emptyList(), new Usage(1, 1), null, "{}");
+
+ // act
+ provider.warnOnTruncatedAnswer(request("class A {}"), empty);
+
+ // assert
+ assertThat(appender.list.isEmpty(), is(true));
+ } finally {
+ detachAppender(appender);
+ }
+ }
+
+ /** The cache line reports all three counts, so a transposition of two of them is visible. */
+ @Test
+ public void logPromptCacheReuse_debugEnabled_reportsCachedTotalAndGenerated() {
+ // arrange
+ final ListAppender appender = attachAppender(Level.DEBUG);
+ try {
+ final LlamaCppJniProviderSupport provider = providerWith(
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
+
+ // act -- distinct values so each lands in its own placeholder
+ provider.logPromptCacheReuse(request("class A {}"), responseWith("stop", new Usage(70, 11, 33)));
+
+ // assert
+ assertThat(appender.list.size(), is(1));
+ final String message = appender.list.get(0).getFormattedMessage();
+ assertThat(message, containsString("33 of 70 prompt token(s)"));
+ assertThat(message, containsString("11 generated"));
+ } finally {
+ detachAppender(appender);
+ }
+ }
+
+ /** At INFO the line must not be built at all -- it is one log line per indexed file. */
+ @Test
+ public void logPromptCacheReuse_debugDisabled_saysNothing() {
+ // arrange
+ final ListAppender appender = attachAppender(Level.INFO);
+ try {
+ final LlamaCppJniProviderSupport provider = providerWith(
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build());
+
+ // act
+ provider.logPromptCacheReuse(request("class A {}"), responseWith("stop", new Usage(70, 11, 33)));
+
+ // assert
+ assertThat(appender.list.isEmpty(), is(true));
+ } finally {
+ detachAppender(appender);
+ }
+ }
+
+ //
+
+ /**
+ * The point of the tri-state. While {@code chatTemplateEnableThinking} was a plain
+ * {@code boolean} defaulting to {@code true}, every run handed {@code enable_thinking} to the
+ * chat template -- including templates that have never heard of it, which llama.cpp's Jinja
+ * layer has been moving from "silently ignored" toward "warned about". Unset must now mean
+ * "say nothing", so the template's own default applies.
+ *
+ * The sibling kwarg is deliberately not asserted away here: {@code reasoningEffort} does
+ * default to {@code "low"} and is therefore still sent by a default run, but unlike
+ * {@code enable_thinking} it has an escape that means exactly "unset" -- the empty string -- so a
+ * non-gpt-oss user can switch it off without picking a value that says something else. That is a
+ * documented choice, not the same defect; the next test pins that escape.
+ */
+ @Test
+ public void buildChatTemplateKwargs_defaultConfig_doesNotSendEnableThinking() {
+ // arrange
+ final LlamaCppJniConfig defaults =
+ LlamaCppJniConfig.builder("/does/not/exist.gguf").build();
+
+ // act
+ final Map kwargs = providerWith(defaults).buildChatTemplateKwargs();
+
+ // assert
+ assertThat(kwargs.containsKey(KWARG_ENABLE_THINKING), is(false));
+ }
+
+ /** The other half of "unset means unset": a blank reasoning effort omits its kwarg too. */
+ @Test
+ public void buildChatTemplateKwargs_blankReasoningEffort_sendsNoKwargAtAll() {
+ // arrange
+ final LlamaCppJniConfig blank = LlamaCppJniConfig.builder("/does/not/exist.gguf")
+ .reasoningEffort("")
+ .build();
+
+ // act
+ final Map kwargs = providerWith(blank).buildChatTemplateKwargs();
+
+ // assert
+ assertThat(kwargs.isEmpty(), is(true));
+ }
+
+ /**
+ * {@code true} is a real configured value, not a second spelling of "unset". A guard written as
+ * "send it only when false" would swallow it, so this pins the boundary from the other side --
+ * the same shape as the {@code 0}-versus-{@code -1} penalty-window pair above.
+ */
+ @Test
+ public void buildChatTemplateKwargs_thinkingSetToTrue_isStillSent() {
+ // arrange
+ final LlamaCppJniConfig enabled = LlamaCppJniConfig.builder("/does/not/exist.gguf")
+ .chatTemplateEnableThinking(Boolean.TRUE)
+ .build();
+
+ // act
+ final Map kwargs = providerWith(enabled).buildChatTemplateKwargs();
+
+ // assert
+ assertThat(kwargs.get(KWARG_ENABLE_THINKING), is("true"));
+ }
+
+ /** The Gemma-4 case the knob exists for: suppress the thinking block at the Jinja level. */
+ @Test
+ public void buildChatTemplateKwargs_thinkingSetToFalse_isSentAsFalse() {
+ // arrange
+ final LlamaCppJniConfig disabled = LlamaCppJniConfig.builder("/does/not/exist.gguf")
+ .chatTemplateEnableThinking(Boolean.FALSE)
+ .build();
+
+ // act
+ final Map kwargs = providerWith(disabled).buildChatTemplateKwargs();
+
+ // assert
+ assertThat(kwargs.get(KWARG_ENABLE_THINKING), is("false"));
+ }
+
+ /**
+ * The second kwarg was already opt-in (blank omits it); pinned here so the extraction of
+ * {@code buildChatTemplateKwargs} out of {@code model()} cannot drop it unnoticed.
+ */
+ @Test
+ public void buildChatTemplateKwargs_reasoningEffortConfigured_isSentAlongside() {
+ // arrange
+ final LlamaCppJniConfig configured = LlamaCppJniConfig.builder("/does/not/exist.gguf")
+ .reasoningEffort("high")
+ .chatTemplateEnableThinking(Boolean.FALSE)
+ .build();
+
+ // act
+ final Map kwargs = providerWith(configured).buildChatTemplateKwargs();
+
+ // assert
+ assertThat(kwargs.get(KWARG_REASONING_EFFORT), is("high"));
+ assertThat(kwargs.get(KWARG_ENABLE_THINKING), is("false"));
+ }
+
+ //
+
+ //
+}