Skip to content

Commit 30bd733

Browse files
committed
feature: add version-aware model ID resolution to test results schema
1 parent e2778d8 commit 30bd733

7 files changed

Lines changed: 575 additions & 77 deletions

File tree

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
"status" : "open"
2+
"status" : "closed",
3+
"resolution" : "implemented",
4+
"target_branch" : "v2.1"
35
}

.cat/issues/v2/v2.1/add-model-id-to-test-results-schema/plan.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,108 @@ model changes.
99

1010
(none)
1111

12+
## Research Findings
13+
14+
### Current State
15+
16+
1. **Skills results.json** (`plugin/tests/skills/*/first-use/results.json`): Has a `model` field but stores
17+
short names like `"haiku"` instead of fully-qualified IDs like `"claude-haiku-4-5-20251001"`. The schema
18+
documentation (`plugin/concepts/skill-test-results.md`) already specifies `"claude-sonnet-4-6"` as the
19+
expected format, but the code does not comply.
20+
21+
2. **Instruction-test.json** (`plugin/skills/*/instruction-test/instruction-test.json`): Written by
22+
`InstructionTestRunner.persistArtifacts()`. Contains `session_id`, `phase`, `timestamp`, `skill` (path +
23+
sha256), `test_cases` (path + sha256) — but NO model field at all.
24+
25+
3. **Rules test-results.json** (`plugin/skills/*/test/test-results.json` and
26+
`plugin/tests/rules/*/test-results.json`): Contains `sprt.session_id` and `sprt.test_cases[]` with SPRT
27+
data — but NO model field at all.
28+
29+
4. **Model extraction**: `InstructionTestRunner.extractModel()` reads the `model:` field from SKILL.md
30+
frontmatter and returns short names (`"haiku"`, `"sonnet"`, `"opus"`). Falls back to `"haiku"` if absent.
31+
32+
5. **No staleness detection**: No existing logic compares stored model against current model to detect stale
33+
cached results.
34+
35+
### Approach: Model ID Resolution
36+
37+
Short model names from SKILL.md frontmatter must be resolved to fully-qualified model IDs. Create a
38+
`ModelIdResolver` utility class in Java that maps short names to their fully-qualified equivalents:
39+
- `haiku``claude-haiku-4-5-20251001`
40+
- `sonnet``claude-sonnet-4-6`
41+
- `opus``claude-opus-4-6`
42+
43+
This mapping is maintained as a static map in Java, easily updated when model versions change.
44+
45+
## Risk Assessment
46+
47+
- **Risk Level:** MEDIUM
48+
- **Concerns:** Existing cached results.json files will have short model names; changing to fully-qualified
49+
IDs means all existing cached results become stale (model mismatch). This is the desired behavior — it
50+
forces re-validation after model changes.
51+
- **Mitigation:** The migration is intentional. Existing results with short names will naturally trigger
52+
re-validation when compared against the new fully-qualified format.
53+
54+
## Files to Modify
55+
56+
- `client/src/main/java/io/github/cowwoc/cat/hooks/skills/ModelIdResolver.java` — NEW: model name resolution utility
57+
- `client/src/main/java/io/github/cowwoc/cat/hooks/skills/InstructionTestRunner.java` — update `extractModel()` to return fully-qualified IDs; update `persistArtifacts()` to write model_id; update `mergeResults()` and `init-sprt` to handle model staleness
58+
- `client/src/test/java/io/github/cowwoc/cat/hooks/test/ModelIdResolverTest.java` — NEW: tests for model resolution
59+
- `client/src/test/java/io/github/cowwoc/cat/hooks/test/InstructionTestRunnerModelIdTest.java` — NEW: tests for model ID in results
60+
- `plugin/concepts/skill-test-results.md` — update schema documentation to include model_id field and staleness semantics
61+
62+
## Jobs
63+
64+
### Job 1
65+
66+
- Create `ModelIdResolver.java` in `client/src/main/java/io/github/cowwoc/cat/hooks/skills/`:
67+
- A final class with a static method `resolve(String shortName)` that maps short model names to fully-qualified model IDs
68+
- Mapping: `haiku``claude-haiku-4-5-20251001`, `sonnet``claude-sonnet-4-6`, `opus``claude-opus-4-6`
69+
- Case-insensitive matching on the short name
70+
- Throws `IllegalArgumentException` for unknown model names
71+
- Include proper Javadoc, license header, and validation per project conventions
72+
- Files: `client/src/main/java/io/github/cowwoc/cat/hooks/skills/ModelIdResolver.java`
73+
74+
- Update `InstructionTestRunner.extractModel()` method to resolve the short model name to a fully-qualified model ID by calling `ModelIdResolver.resolve()` on the value read from SKILL.md frontmatter
75+
- Files: `client/src/main/java/io/github/cowwoc/cat/hooks/skills/InstructionTestRunner.java`
76+
77+
- Update `InstructionTestRunner.persistArtifacts()` to include a `"model_id"` field in the instruction-test.json output:
78+
- Add a new argument to `persistArtifacts()` for the model_id, OR extract it from the skill path (read SKILL.md frontmatter and resolve)
79+
- Write `root.put("model_id", modelId)` in the JSON output alongside `session_id`, `phase`, `timestamp`
80+
- Files: `client/src/main/java/io/github/cowwoc/cat/hooks/skills/InstructionTestRunner.java`
81+
82+
- Update `InstructionTestRunner.mergeResults()` to check model staleness:
83+
- When reading prior instruction-test.json, extract the `model_id` field
84+
- Compare against the current model_id (resolved from skill frontmatter)
85+
- If model_id differs (or is absent in prior results), treat ALL prior results as stale — do not carry forward any SPRT states
86+
- Log a message: "Model changed from {prior} to {current}, invalidating cached SPRT results"
87+
- Files: `client/src/main/java/io/github/cowwoc/cat/hooks/skills/InstructionTestRunner.java`
88+
89+
- Update `InstructionTestRunner` init-sprt command to check model staleness:
90+
- When reading prior instruction-test results for carry-forward, check model_id
91+
- If prior model_id differs from current, skip carry-forward entirely (start fresh SPRT)
92+
- Files: `client/src/main/java/io/github/cowwoc/cat/hooks/skills/InstructionTestRunner.java`
93+
94+
- Create `ModelIdResolverTest.java` in `client/src/test/java/io/github/cowwoc/cat/hooks/test/`:
95+
- Test that `resolve("haiku")` returns `"claude-haiku-4-5-20251001"`
96+
- Test that `resolve("sonnet")` returns `"claude-sonnet-4-6"`
97+
- Test that `resolve("opus")` returns `"claude-opus-4-6"`
98+
- Test case-insensitive matching: `resolve("HAIKU")` returns `"claude-haiku-4-5-20251001"`
99+
- Test that unknown names throw `IllegalArgumentException`
100+
- Files: `client/src/test/java/io/github/cowwoc/cat/hooks/test/ModelIdResolverTest.java`
101+
102+
- Update `plugin/concepts/skill-test-results.md` schema documentation:
103+
- Add `model_id` field to the top-level fields table (fully-qualified model identifier, e.g., `claude-haiku-4-5-20251001`)
104+
- Document the `model` field as the short name from SKILL.md frontmatter (for human reference)
105+
- Add a "Staleness Detection" section explaining that when `model_id` in cached results differs from the current model's fully-qualified ID, all cached SPRT results are invalidated
106+
- Update the example JSON to include a `model_id` field
107+
- Files: `plugin/concepts/skill-test-results.md`
108+
109+
- Update index.json to status: closed, progress: 100%
110+
- Files: `.cat/issues/v2/v2.1/add-model-id-to-test-results-schema/index.json`
111+
112+
- Run `mvn -f client/pom.xml verify -e` to verify all tests pass
113+
12114
## Post-conditions
13115

14116
- [ ] Both skills results.json and rules test-results.json schemas include a fully-qualified model ID field (e.g., claude-haiku-4-5-20251001)

client/src/main/java/io/github/cowwoc/cat/hooks/skills/InstructionTestRunner.java

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,22 @@ public final class InstructionTestRunner
109109

110110
private final Logger log = LoggerFactory.getLogger(InstructionTestRunner.class);
111111
private final JvmScope scope;
112+
private final String claudeCodeVersion;
112113

113114
/**
114115
* Creates a new InstructionTestRunner.
115116
*
116-
* @param scope the JVM scope providing shared services
117-
* @throws NullPointerException if {@code scope} is null
117+
* @param scope the JVM scope providing shared services
118+
* @param claudeCodeVersion the Claude Code version string (e.g., {@code "2.1.87"})
119+
* @throws NullPointerException if {@code scope} or {@code claudeCodeVersion} are null
120+
* @throws IllegalArgumentException if {@code claudeCodeVersion} is blank
118121
*/
119-
public InstructionTestRunner(JvmScope scope)
122+
public InstructionTestRunner(JvmScope scope, String claudeCodeVersion)
120123
{
121124
requireThat(scope, "scope").isNotNull();
125+
requireThat(claudeCodeVersion, "claudeCodeVersion").isNotBlank();
122126
this.scope = scope;
127+
this.claudeCodeVersion = claudeCodeVersion;
123128
}
124129

125130
/**
@@ -191,11 +196,12 @@ public String extractUnits(String[] args) throws IOException
191196
/**
192197
* Implements the {@code extract-model} command.
193198
* <p>
194-
* Reads the YAML frontmatter of the skill and returns the value of the {@code model:} field,
195-
* falling back to {@code "haiku"} when the field is absent.
199+
* Reads the YAML frontmatter of the skill and returns the fully-qualified model identifier.
200+
* The short name from the {@code model:} field is resolved via {@link ModelIdResolver}.
201+
* Falls back to {@code "haiku"} (resolved to its fully-qualified ID) when the field is absent.
196202
*
197203
* @param args {@code [skill_path]}
198-
* @return the model name
204+
* @return the fully-qualified model identifier
199205
* @throws IllegalArgumentException if the argument count is wrong or the file is not found
200206
* @throws IOException if the file cannot be read
201207
*/
@@ -215,11 +221,11 @@ public String extractModel(String[] args) throws IOException
215221
String model = SkillDiscovery.extractField(parsed.frontmatter(), "model");
216222
if (model.isBlank())
217223
{
218-
log.warn("InstructionTestRunner extract-model: no 'model:' field in frontmatter of {}; falling back to 'haiku'",
219-
skillPath);
220-
return "haiku";
224+
throw new IllegalArgumentException(
225+
"InstructionTestRunner extract-model: no 'model:' field in frontmatter of " +
226+
skillPath + ". Every skill must declare a model.");
221227
}
222-
return model;
228+
return ModelIdResolver.resolve(claudeCodeVersion, model);
223229
}
224230

225231
/**
@@ -525,11 +531,23 @@ public void persistArtifacts(String[] args, PrintStream out) throws IOException
525531

526532
String timestamp = ISO_UTC.format(Instant.now());
527533

534+
// Resolve model_id from skill frontmatter
535+
ParsedSkill parsed = parseSkill(absSkillPath);
536+
String model = SkillDiscovery.extractField(parsed.frontmatter(), "model");
537+
if (model.isBlank())
538+
{
539+
throw new IllegalArgumentException(
540+
"InstructionTestRunner persist-artifacts: no 'model:' field in frontmatter of " +
541+
absSkillPath + ". Every skill must declare a model.");
542+
}
543+
String modelId = ModelIdResolver.resolve(claudeCodeVersion, model);
544+
528545
// Write instruction-test.json using Jackson to ensure proper escaping and formatting
529546
Path instructionTestJsonPath = instructionTestDir.resolve("instruction-test.json");
530547
JsonMapper mapper = scope.getJsonMapper();
531548
ObjectNode root = mapper.createObjectNode();
532549
root.put("session_id", sessionId);
550+
root.put("model_id", modelId);
533551
root.put("phase", phase);
534552
root.put("timestamp", timestamp);
535553
ObjectNode skillNode = root.putObject("skill");
@@ -596,25 +614,29 @@ public void persistArtifacts(String[] args, PrintStream out) throws IOException
596614
* Implements the {@code init-sprt} command.
597615
* <p>
598616
* Initialises per-test-case SPRT state: fresh state for re-run cases, and carry-forward state
599-
* from the prior instruction-test for unchanged cases.
617+
* from the prior instruction-test for unchanged cases. When the prior instruction-test was produced
618+
* by a different model (detected via the {@code model_id} field), all prior results are treated as
619+
* stale and carry-forward is skipped entirely.
600620
*
601-
* @param args {@code [rerun_tc_ids_json, prior_instruction_test_json_path, (--prior-boost)?]}
621+
* @param args {@code [rerun_tc_ids_json, prior_instruction_test_json_path, current_model_id, (--prior-boost)?]}
602622
* @return a JSON object containing the {@code sprt_state} map
603623
* @throws IllegalArgumentException if arguments are missing or the prior file is not found
604624
* @throws IOException if the prior file cannot be read
605625
*/
606626
public String initSprt(String[] args) throws IOException
607627
{
608628
requireThat(args, "args").isNotNull();
609-
if (args.length < 2)
629+
if (args.length < 3)
610630
throw new IllegalArgumentException(
611-
"InstructionTestRunner init-sprt: expected at least 2 arguments, got " + args.length + ".\n" +
612-
"Usage: skill-test-runner init-sprt <rerun_tc_ids_json> <prior_instruction_test_json_path> [--prior-boost]");
631+
"InstructionTestRunner init-sprt: expected at least 3 arguments, got " + args.length + ".\n" +
632+
"Usage: skill-test-runner init-sprt <rerun_tc_ids_json> <prior_instruction_test_json_path> " +
633+
"<current_model_id> [--prior-boost]");
613634

614635
String rerunJson = args[0];
615636
String priorPath = args[1];
637+
String currentModelId = args[2];
616638
boolean usePriorBoost = false;
617-
for (int i = 2; i < args.length; ++i)
639+
for (int i = 3; i < args.length; ++i)
618640
{
619641
if (args[i].equals("--prior-boost"))
620642
usePriorBoost = true;
@@ -641,18 +663,32 @@ public String initSprt(String[] args) throws IOException
641663

642664
// Read prior instruction-test (if any), building a lookup map in a single pass over test_cases
643665
// so priorIds can be derived from the map's key set without a second traversal.
666+
// When the prior model_id differs from the current model, invalidate all cached results.
644667
Map<String, JsonNode> priorByTestCaseId = new HashMap<>();
645668
if (hasPrior)
646669
{
647670
JsonNode priorRoot = mapper.readTree(Path.of(priorPath).toFile());
648-
JsonNode priorTestCases = priorRoot.path("test_cases");
649-
if (priorTestCases.isArray())
671+
String priorModelId = priorRoot.path("model_id").asString("");
672+
// Only carry forward prior results when model_id matches the current model
673+
boolean modelMatches = !priorModelId.isBlank() && priorModelId.equals(currentModelId);
674+
if (!priorModelId.isBlank() && !priorModelId.equals(currentModelId))
650675
{
651-
for (JsonNode tc : priorTestCases)
676+
log.warn("Model changed from {} to {}, invalidating cached SPRT results",
677+
priorModelId, currentModelId);
678+
}
679+
else if (priorModelId.isBlank())
680+
log.warn("Prior instruction-test has no model_id, invalidating cached SPRT results");
681+
if (modelMatches)
682+
{
683+
JsonNode priorTestCases = priorRoot.path("test_cases");
684+
if (priorTestCases.isArray())
652685
{
653-
String tcId = tc.path("test_case_id").asString("");
654-
if (!tcId.isBlank())
655-
priorByTestCaseId.put(tcId, tc);
686+
for (JsonNode tc : priorTestCases)
687+
{
688+
String tcId = tc.path("test_case_id").asString("");
689+
if (!tcId.isBlank())
690+
priorByTestCaseId.put(tcId, tc);
691+
}
656692
}
657693
}
658694
}
@@ -937,25 +973,27 @@ public String smokeStatus(String[] args) throws IOException
937973
* Implements the {@code merge-results} command.
938974
* <p>
939975
* Merges new SPRT decisions with carried-forward results to produce a complete instruction-test.json
940-
* summary ready for committing.
976+
* summary ready for committing. The {@code model_id} parameter is included in the output to enable
977+
* staleness detection on subsequent runs.
941978
*
942-
* @param args {@code [new_sprt_state_path, prior_instruction_test_json_path, carryforward_ids_json]}
943-
* @return a JSON object with overall_decision, timestamp, incremental flag, and test_cases
979+
* @param args {@code [new_sprt_state_path, prior_instruction_test_json_path, carryforward_ids_json, model_id]}
980+
* @return a JSON object with model_id, overall_decision, timestamp, incremental flag, and test_cases
944981
* @throws IllegalArgumentException if arguments are invalid or the state file is not found
945982
* @throws IOException if files cannot be read
946983
*/
947984
public String mergeResults(String[] args) throws IOException
948985
{
949986
requireThat(args, "args").isNotNull();
950-
if (args.length != 3)
987+
if (args.length != 4)
951988
throw new IllegalArgumentException(
952-
"InstructionTestRunner merge-results: expected 3 arguments, got " + args.length + ".\n" +
989+
"InstructionTestRunner merge-results: expected 4 arguments, got " + args.length + ".\n" +
953990
"Usage: skill-test-runner merge-results <new_sprt_state_path> " +
954-
"<prior_instruction_test_json_path> <carryforward_ids_json>");
991+
"<prior_instruction_test_json_path> <carryforward_ids_json> <model_id>");
955992

956993
Path statePath = Path.of(args[0]);
957994
String priorInstructionTestPath = args[1];
958995
String carryforwardIdsJson = args[2];
996+
String modelId = args[3];
959997

960998
if (Files.notExists(statePath))
961999
throw new IllegalArgumentException(
@@ -1062,6 +1100,7 @@ else if (decision.equals("INCONCLUSIVE") && !overallDecision.equals("REJECT"))
10621100

10631101
String timestamp = ISO_UTC.format(Instant.now());
10641102
ObjectNode result = mapper.createObjectNode();
1103+
result.put("model_id", modelId);
10651104
result.put("timestamp", timestamp);
10661105
result.put("overall_decision", overallDecision);
10671106
result.put("incremental", true);
@@ -1359,6 +1398,7 @@ public static void run(JvmScope scope, String[] args, PrintStream out) throws IO
13591398
requireThat(scope, "scope").isNotNull();
13601399
requireThat(args, "args").isNotNull();
13611400
requireThat(out, "out").isNotNull();
1362-
new InstructionTestRunner(scope).run(args, out);
1401+
String claudeCodeVersion = ModelIdResolver.detectClaudeCodeVersion();
1402+
new InstructionTestRunner(scope, claudeCodeVersion).run(args, out);
13631403
}
13641404
}

0 commit comments

Comments
 (0)