Skip to content

Commit 7b707d4

Browse files
committed
Drive ChatResponseParser and CompletionResponseParser to 100% PIT coverage
Closes the two remaining near-100% json parsers flagged in the cross-repo mutation-coverage expansion and adds both to the gated targetClasses list. Production (behaviour-preserving refactors that remove equivalent mutants): - parseChoices / parseToolCalls: collapse the "guard returns empty list / body builds a list" two-branch shape into a single mutable-ArrayList return. An empty or non-array input now falls through the loop and returns the same empty ArrayList, so the immutable-emptyList() empty-branch mutant and the redundant arr.size()==0 conditional mutants disappear, while the Error Prone MixedMutabilityReturnType contract is still satisfied. - parseLogprobs: same single-return collapse. - parseLogprobEntry: drop the emptyList()/ArrayList ternary for nested alternatives in favour of a single mutable list filled only when the nested array is present, removing the size()>0 boundary mutant. Tests (no behaviour change; cover the previously untested typed-parse paths): - ChatResponseParserTest: parseResponse full/multi-choice/tool-calls (string + object arguments)/plain/empty-choices(mutable contract)/absent/ malformed, plus a LogCaptor test pinning the TimingsLogger.log side-effect. - CompletionResponseParserTest: parseLogprobs post- and pre-sampling (top_probs vs top_logprobs), nested recursion, missing id, empty/absent array (mutable contract); parseCompletionResult full/limit/malformed and a LogCaptor timing-line test. Verified: ChatResponseParser + CompletionResponseParser 39/39 mutations killed; full jllama PIT gate 207/207 (100%); spotless:check clean; the two test classes 65/65 green.
1 parent d291940 commit 7b707d4

5 files changed

Lines changed: 325 additions & 38 deletions

File tree

pom.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,8 @@ SPDX-License-Identifier: MIT
662662
<param>net.ladenthin.llama.args.*</param>
663663
<param>net.ladenthin.llama.json.TimingsLogger</param>
664664
<param>net.ladenthin.llama.json.RerankResponseParser</param>
665+
<param>net.ladenthin.llama.json.ChatResponseParser</param>
666+
<param>net.ladenthin.llama.json.CompletionResponseParser</param>
665667
</targetClasses>
666668
<targetTests>
667669
<param>net.ladenthin.llama.value.*</param>

src/main/java/net/ladenthin/llama/json/ChatResponseParser.java

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -163,36 +163,40 @@ public ChatResponse parseResponse(String json) {
163163
}
164164

165165
private List<ChatChoice> parseChoices(JsonNode arr) {
166-
// Mutable ArrayList on both branches keeps the return-type contract consistent
167-
// (Error Prone MixedMutabilityReturnType).
168-
if (!arr.isArray() || arr.size() == 0) return new ArrayList<>();
169-
List<ChatChoice> out = new ArrayList<ChatChoice>(arr.size());
170-
for (JsonNode c : arr) {
171-
int index = c.path("index").asInt(0);
172-
JsonNode msg = c.path("message");
173-
String role = msg.path("role").asText("assistant");
174-
String content = msg.path("content").asText("");
175-
List<ToolCall> toolCalls = parseToolCalls(msg.path("tool_calls"));
176-
ChatMessage message = toolCalls.isEmpty()
177-
? new ChatMessage(role, content)
178-
: ChatMessage.assistantToolCalls(content, toolCalls);
179-
String finishReason = c.path("finish_reason").asText("");
180-
out.add(new ChatChoice(index, message, finishReason));
166+
// Single mutable-ArrayList return: an empty (or non-array) input falls
167+
// through the loop and returns the same empty ArrayList, keeping the
168+
// return-type contract consistent (Error Prone MixedMutabilityReturnType)
169+
// and leaving no equivalent empty-branch mutant for PIT to flag.
170+
List<ChatChoice> out = new ArrayList<>();
171+
if (arr.isArray()) {
172+
for (JsonNode c : arr) {
173+
int index = c.path("index").asInt(0);
174+
JsonNode msg = c.path("message");
175+
String role = msg.path("role").asText("assistant");
176+
String content = msg.path("content").asText("");
177+
List<ToolCall> toolCalls = parseToolCalls(msg.path("tool_calls"));
178+
ChatMessage message = toolCalls.isEmpty()
179+
? new ChatMessage(role, content)
180+
: ChatMessage.assistantToolCalls(content, toolCalls);
181+
String finishReason = c.path("finish_reason").asText("");
182+
out.add(new ChatChoice(index, message, finishReason));
183+
}
181184
}
182185
return out;
183186
}
184187

185188
private List<ToolCall> parseToolCalls(JsonNode arr) {
186-
if (!arr.isArray() || arr.size() == 0) return new ArrayList<>();
187-
List<ToolCall> out = new ArrayList<ToolCall>(arr.size());
188-
for (JsonNode tc : arr) {
189-
String id = tc.path("id").asText("");
190-
JsonNode fn = tc.path("function");
191-
String name = fn.path("name").asText("");
192-
JsonNode argsNode = fn.path("arguments");
193-
// OAI emits arguments as a string; some shapes emit a nested object.
194-
String args = argsNode.isTextual() ? argsNode.asText("") : argsNode.toString();
195-
out.add(new ToolCall(id, name, args));
189+
List<ToolCall> out = new ArrayList<>();
190+
if (arr.isArray()) {
191+
for (JsonNode tc : arr) {
192+
String id = tc.path("id").asText("");
193+
JsonNode fn = tc.path("function");
194+
String name = fn.path("name").asText("");
195+
JsonNode argsNode = fn.path("arguments");
196+
// OAI emits arguments as a string; some shapes emit a nested object.
197+
String args = argsNode.isTextual() ? argsNode.asText("") : argsNode.toString();
198+
out.add(new ToolCall(id, name, args));
199+
}
196200
}
197201
return out;
198202
}

src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -158,14 +158,15 @@ public Map<String, Float> parseProbabilities(JsonNode root) {
158158
*/
159159
public List<TokenLogprob> parseLogprobs(JsonNode root) {
160160
JsonNode array = root.path("completion_probabilities");
161-
if (!array.isArray() || array.size() == 0) {
162-
// Return a mutable empty ArrayList to keep the return type consistent
163-
// with the non-empty branch below (Error Prone MixedMutabilityReturnType).
164-
return new ArrayList<>();
165-
}
166-
List<TokenLogprob> result = new ArrayList<TokenLogprob>(array.size());
167-
for (JsonNode entry : array) {
168-
result.add(parseLogprobEntry(entry));
161+
// Single mutable-ArrayList return: an empty (or absent) array falls
162+
// through the loop and returns the same empty ArrayList, keeping the
163+
// return type consistent (Error Prone MixedMutabilityReturnType) and
164+
// leaving no equivalent empty-branch mutant for PIT to flag.
165+
List<TokenLogprob> result = new ArrayList<>();
166+
if (array.isArray()) {
167+
for (JsonNode entry : array) {
168+
result.add(parseLogprobEntry(entry));
169+
}
169170
}
170171
return result;
171172
}
@@ -219,14 +220,14 @@ private TokenLogprob parseLogprobEntry(JsonNode entry) {
219220
if (!top.isArray()) {
220221
top = entry.path("top_logprobs");
221222
}
222-
List<TokenLogprob> topLogprobs;
223-
if (top.isArray() && top.size() > 0) {
224-
topLogprobs = new ArrayList<TokenLogprob>(top.size());
223+
// Single mutable-ArrayList accumulation: a missing or empty nested array
224+
// skips the loop and yields an empty ArrayList, so there is no equivalent
225+
// empty-branch mutant (the prior emptyList()/ArrayList ternary left one).
226+
List<TokenLogprob> topLogprobs = new ArrayList<>();
227+
if (top.isArray()) {
225228
for (JsonNode t : top) {
226229
topLogprobs.add(parseLogprobEntry(t));
227230
}
228-
} else {
229-
topLogprobs = Collections.emptyList();
230231
}
231232
return new TokenLogprob(token, tokenId, logprob, topLogprobs);
232233
}

src/test/java/net/ladenthin/llama/json/ChatResponseParserTest.java

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010
import com.fasterxml.jackson.databind.JsonNode;
1111
import com.fasterxml.jackson.databind.ObjectMapper;
12+
import java.util.List;
13+
import net.ladenthin.llama.value.ChatChoice;
14+
import net.ladenthin.llama.value.ChatMessage;
15+
import net.ladenthin.llama.value.ChatResponse;
16+
import net.ladenthin.llama.value.ToolCall;
17+
import nl.altindag.log.LogCaptor;
1218
import org.junit.jupiter.api.Test;
1319

1420
/**
@@ -211,4 +217,136 @@ public void testCountChoices_absent() throws Exception {
211217
JsonNode node = MAPPER.readTree("{\"id\":\"x\"}");
212218
assertEquals(0, parser.countChoices(node));
213219
}
220+
221+
// ------------------------------------------------------------------
222+
// parseResponse(String) — full typed parse
223+
// ------------------------------------------------------------------
224+
225+
@Test
226+
public void testParseResponse_fullResponse() {
227+
String json = "{\"id\":\"chatcmpl-abc\",\"choices\":[{\"index\":0,"
228+
+ "\"message\":{\"role\":\"assistant\",\"content\":\"Hi there\"},"
229+
+ "\"finish_reason\":\"stop\"}],"
230+
+ "\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}}";
231+
ChatResponse r = parser.parseResponse(json);
232+
233+
assertEquals("chatcmpl-abc", r.getId());
234+
assertEquals(1, r.getChoices().size());
235+
ChatChoice c = r.getChoices().get(0);
236+
assertEquals(0, c.getIndex());
237+
assertEquals("assistant", c.getMessage().getRole());
238+
assertEquals("Hi there", c.getMessage().getContent());
239+
assertEquals("stop", c.getFinishReason());
240+
assertEquals(7L, r.getUsage().getPromptTokens());
241+
assertEquals(3L, r.getUsage().getCompletionTokens());
242+
assertEquals(json, r.getRawJson());
243+
}
244+
245+
@Test
246+
public void testParseResponse_multipleChoicesPreserveIndexAndOrder() {
247+
String json = "{\"id\":\"x\",\"choices\":["
248+
+ "{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"first\"},\"finish_reason\":\"stop\"},"
249+
+ "{\"index\":1,\"message\":{\"role\":\"assistant\",\"content\":\"second\"},\"finish_reason\":\"length\"}"
250+
+ "]}";
251+
ChatResponse r = parser.parseResponse(json);
252+
253+
assertEquals(2, r.getChoices().size());
254+
assertEquals(0, r.getChoices().get(0).getIndex());
255+
assertEquals("first", r.getChoices().get(0).getMessage().getContent());
256+
assertEquals(1, r.getChoices().get(1).getIndex());
257+
assertEquals("second", r.getChoices().get(1).getMessage().getContent());
258+
assertEquals("length", r.getChoices().get(1).getFinishReason());
259+
}
260+
261+
@Test
262+
public void testParseResponse_toolCallsWithStringArguments() {
263+
String json = "{\"id\":\"x\",\"choices\":[{\"index\":0,"
264+
+ "\"message\":{\"role\":\"assistant\",\"content\":\"\","
265+
+ "\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\","
266+
+ "\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"NYC\\\"}\"}}]},"
267+
+ "\"finish_reason\":\"tool_calls\"}]}";
268+
ChatResponse r = parser.parseResponse(json);
269+
270+
ChatMessage m = r.getChoices().get(0).getMessage();
271+
List<ToolCall> tcs = m.getToolCalls();
272+
assertEquals(1, tcs.size());
273+
assertEquals("call_1", tcs.get(0).getId());
274+
assertEquals("get_weather", tcs.get(0).getName());
275+
// arguments is a JSON string in the wire form → unwrapped verbatim, not re-quoted.
276+
assertEquals("{\"city\":\"NYC\"}", tcs.get(0).getArgumentsJson());
277+
}
278+
279+
@Test
280+
public void testParseResponse_toolCallsWithObjectArguments() {
281+
// Some shapes emit arguments as a nested object rather than a string;
282+
// the parser serialises it back to its JSON text.
283+
String json = "{\"id\":\"x\",\"choices\":[{\"index\":0,"
284+
+ "\"message\":{\"role\":\"assistant\",\"content\":\"\","
285+
+ "\"tool_calls\":[{\"id\":\"call_2\","
286+
+ "\"function\":{\"name\":\"f\",\"arguments\":{\"a\":1}}}]}}]}";
287+
ChatResponse r = parser.parseResponse(json);
288+
289+
ToolCall tc = r.getChoices().get(0).getMessage().getToolCalls().get(0);
290+
assertEquals("{\"a\":1}", tc.getArgumentsJson());
291+
}
292+
293+
@Test
294+
public void testParseResponse_noToolCalls_plainAssistantMessage() {
295+
String json = "{\"id\":\"x\",\"choices\":[{\"index\":0,"
296+
+ "\"message\":{\"role\":\"assistant\",\"content\":\"plain\"}}]}";
297+
ChatResponse r = parser.parseResponse(json);
298+
299+
ChatMessage m = r.getChoices().get(0).getMessage();
300+
assertEquals("plain", m.getContent());
301+
assertTrue(m.getToolCalls().isEmpty(), "plain message carries no tool calls");
302+
}
303+
304+
@Test
305+
public void testParseResponse_emptyChoicesArray_returnsMutableEmptyList() {
306+
ChatResponse r = parser.parseResponse("{\"id\":\"x\",\"choices\":[]}");
307+
assertTrue(r.getChoices().isEmpty());
308+
// The choices list is exposed by reference and documented as mutable —
309+
// adding to it must not throw (kills the immutable-emptyList() mutant).
310+
r.getChoices().add(new ChatChoice(0, new ChatMessage("assistant", "added"), "stop"));
311+
assertEquals(1, r.getChoices().size());
312+
}
313+
314+
@Test
315+
public void testParseResponse_absentChoices_returnsEmptyList() {
316+
ChatResponse r = parser.parseResponse("{\"id\":\"x\"}");
317+
assertEquals("x", r.getId());
318+
assertTrue(r.getChoices().isEmpty());
319+
}
320+
321+
@Test
322+
public void testParseResponse_malformedJson_returnsEmptyResponsePreservingRawJson() {
323+
String bad = "{not valid json";
324+
ChatResponse r = parser.parseResponse(bad);
325+
assertEquals("", r.getId());
326+
assertTrue(r.getChoices().isEmpty());
327+
assertEquals(0L, r.getUsage().getPromptTokens());
328+
assertEquals(0L, r.getUsage().getCompletionTokens());
329+
// Raw JSON is preserved verbatim even on parse failure (escape hatch).
330+
assertEquals(bad, r.getRawJson());
331+
}
332+
333+
/**
334+
* Parsing a response carrying real timings must emit exactly one per-run
335+
* timing line through the dedicated SLF4J logger — pins the {@code
336+
* TimingsLogger.log(...)} side-effect so its removal (VoidMethodCall mutant)
337+
* is detected.
338+
*/
339+
@Test
340+
public void testParseResponse_emitsTimingLine() {
341+
String json = "{\"id\":\"x\",\"choices\":[{\"index\":0,"
342+
+ "\"message\":{\"role\":\"assistant\",\"content\":\"ok\"}}],"
343+
+ "\"timings\":{\"prompt_n\":7,\"prompt_ms\":10.0,\"prompt_per_second\":700.0,"
344+
+ "\"predicted_n\":3,\"predicted_ms\":20.0,\"predicted_per_second\":150.0}}";
345+
346+
try (LogCaptor captor = LogCaptor.forName(TimingsLogger.LOGGER_NAME)) {
347+
ChatResponse r = parser.parseResponse(json);
348+
assertEquals(7, r.getTimings().getPromptN());
349+
assertEquals(1, captor.getInfoLogs().size(), "exactly one timing line must be emitted");
350+
}
351+
}
214352
}

0 commit comments

Comments
 (0)