Skip to content

Commit 6b5bc62

Browse files
Spike: instruction resolver with most-specificity rule
Add InstructionRule and InstructionResolver types that enable associating instructions with specific sets of tools rather than whole toolsets. Key features: - Rules match when ALL specified tools are present in active tools - Most-specific rule wins: superset rules shadow subset rules - Partial overlaps (neither superset) result in both rules applying - Deterministic output via sorted results This is exploratory work to evaluate finer-grained instruction control.
1 parent e81f120 commit 6b5bc62

File tree

2 files changed

+363
-0
lines changed

2 files changed

+363
-0
lines changed

pkg/github/instructions.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,164 @@ package github
33
import (
44
"os"
55
"slices"
6+
"sort"
67
"strings"
78
)
89

10+
// =============================================================================
11+
// SPIKE: Tool-based instruction specificity rules
12+
// =============================================================================
13+
14+
// InstructionRule associates an instruction with a set of tools.
15+
// Rules with more specific tool sets (supersets) take precedence over
16+
// rules with fewer tools when the same tools are involved.
17+
type InstructionRule struct {
18+
// ID is a unique identifier for the rule (for debugging/logging)
19+
ID string
20+
// ToolNames is the set of tool names this rule applies to.
21+
// The rule is active when ALL of these tools are present in the active set.
22+
ToolNames map[string]bool
23+
// Instruction is the instruction text for this rule.
24+
Instruction string
25+
}
26+
27+
// NewInstructionRule creates an InstructionRule from a list of tool names.
28+
func NewInstructionRule(id string, instruction string, toolNames ...string) InstructionRule {
29+
tools := make(map[string]bool, len(toolNames))
30+
for _, name := range toolNames {
31+
tools[name] = true
32+
}
33+
return InstructionRule{
34+
ID: id,
35+
ToolNames: tools,
36+
Instruction: instruction,
37+
}
38+
}
39+
40+
// InstructionResolver resolves which instructions apply based on the
41+
// most-specificity rule: when multiple rules match, rules that are
42+
// supersets of other matching rules shadow those smaller rules.
43+
type InstructionResolver struct {
44+
rules []InstructionRule
45+
}
46+
47+
// NewInstructionResolver creates a new resolver with the given rules.
48+
func NewInstructionResolver(rules []InstructionRule) *InstructionResolver {
49+
return &InstructionResolver{rules: rules}
50+
}
51+
52+
// isSubset returns true if a is a subset of b (all elements of a are in b).
53+
func isSubset(a, b map[string]bool) bool {
54+
for key := range a {
55+
if !b[key] {
56+
return false
57+
}
58+
}
59+
return true
60+
}
61+
62+
// isProperSubset returns true if a is a proper subset of b (a ⊂ b, not equal).
63+
func isProperSubset(a, b map[string]bool) bool {
64+
return len(a) < len(b) && isSubset(a, b)
65+
}
66+
67+
// matchingRule represents a rule that matched the active tools.
68+
type matchingRule struct {
69+
rule InstructionRule
70+
shadowed bool
71+
}
72+
73+
// ResolveInstructions returns the instructions that apply to the given active tools,
74+
// using the most-specificity rule to eliminate shadowed rules.
75+
//
76+
// A rule is "shadowed" if another matching rule's ToolNames is a proper superset
77+
// of this rule's ToolNames. The idea is that the more specific rule (covering more
78+
// tools) should take precedence.
79+
func (r *InstructionResolver) ResolveInstructions(activeTools []string) []string {
80+
// Build active tools set for O(1) lookup
81+
activeSet := make(map[string]bool, len(activeTools))
82+
for _, tool := range activeTools {
83+
activeSet[tool] = true
84+
}
85+
86+
// Find all rules where ALL required tools are present in activeSet
87+
var matches []matchingRule
88+
for _, rule := range r.rules {
89+
if isSubset(rule.ToolNames, activeSet) {
90+
matches = append(matches, matchingRule{rule: rule})
91+
}
92+
}
93+
94+
// Apply shadowing: mark rules as shadowed if another rule is a proper superset
95+
for i := range matches {
96+
for j := range matches {
97+
if i == j {
98+
continue
99+
}
100+
// If rule j's tools are a proper superset of rule i's tools,
101+
// then rule i is shadowed by rule j
102+
if isProperSubset(matches[i].rule.ToolNames, matches[j].rule.ToolNames) {
103+
matches[i].shadowed = true
104+
break // Once shadowed, no need to check further
105+
}
106+
}
107+
}
108+
109+
// Collect non-shadowed instructions, sorted by rule ID for determinism
110+
var result []string
111+
for _, m := range matches {
112+
if !m.shadowed {
113+
result = append(result, m.rule.Instruction)
114+
}
115+
}
116+
117+
// Sort for deterministic output (by instruction content as a simple approach)
118+
sort.Strings(result)
119+
120+
return result
121+
}
122+
123+
// MatchingRuleIDs returns the IDs of rules that match and are not shadowed.
124+
// Useful for debugging/testing.
125+
func (r *InstructionResolver) MatchingRuleIDs(activeTools []string) []string {
126+
activeSet := make(map[string]bool, len(activeTools))
127+
for _, tool := range activeTools {
128+
activeSet[tool] = true
129+
}
130+
131+
var matches []matchingRule
132+
for _, rule := range r.rules {
133+
if isSubset(rule.ToolNames, activeSet) {
134+
matches = append(matches, matchingRule{rule: rule})
135+
}
136+
}
137+
138+
for i := range matches {
139+
for j := range matches {
140+
if i == j {
141+
continue
142+
}
143+
if isProperSubset(matches[i].rule.ToolNames, matches[j].rule.ToolNames) {
144+
matches[i].shadowed = true
145+
break
146+
}
147+
}
148+
}
149+
150+
var ids []string
151+
for _, m := range matches {
152+
if !m.shadowed {
153+
ids = append(ids, m.rule.ID)
154+
}
155+
}
156+
sort.Strings(ids)
157+
return ids
158+
}
159+
160+
// =============================================================================
161+
// END SPIKE
162+
// =============================================================================
163+
9164
// GenerateInstructions creates server instructions based on enabled toolsets
10165
func GenerateInstructions(enabledToolsets []string) string {
11166
// For testing - add a flag to disable instructions

pkg/github/instructions_test.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,211 @@ func TestGetToolsetInstructions(t *testing.T) {
184184
})
185185
}
186186
}
187+
188+
// =============================================================================
189+
// SPIKE TESTS: InstructionResolver with most-specificity rule
190+
// =============================================================================
191+
192+
func TestInstructionResolver_BasicMatching(t *testing.T) {
193+
resolver := NewInstructionResolver([]InstructionRule{
194+
NewInstructionRule("rule-a", "Instruction A", "tool1"),
195+
NewInstructionRule("rule-b", "Instruction B", "tool2"),
196+
NewInstructionRule("rule-c", "Instruction C", "tool3"),
197+
})
198+
199+
tests := []struct {
200+
name string
201+
activeTools []string
202+
expectedIDs []string
203+
expectedInst []string
204+
}{
205+
{
206+
name: "single tool matches single rule",
207+
activeTools: []string{"tool1"},
208+
expectedIDs: []string{"rule-a"},
209+
expectedInst: []string{"Instruction A"},
210+
},
211+
{
212+
name: "two tools match two rules",
213+
activeTools: []string{"tool1", "tool2"},
214+
expectedIDs: []string{"rule-a", "rule-b"},
215+
expectedInst: []string{"Instruction A", "Instruction B"},
216+
},
217+
{
218+
name: "no matching tools",
219+
activeTools: []string{"tool4"},
220+
expectedIDs: []string{},
221+
expectedInst: []string{},
222+
},
223+
{
224+
name: "all tools match all rules",
225+
activeTools: []string{"tool1", "tool2", "tool3"},
226+
expectedIDs: []string{"rule-a", "rule-b", "rule-c"},
227+
expectedInst: []string{"Instruction A", "Instruction B", "Instruction C"},
228+
},
229+
}
230+
231+
for _, tt := range tests {
232+
t.Run(tt.name, func(t *testing.T) {
233+
ids := resolver.MatchingRuleIDs(tt.activeTools)
234+
instructions := resolver.ResolveInstructions(tt.activeTools)
235+
236+
if len(ids) != len(tt.expectedIDs) {
237+
t.Errorf("Expected %d rule IDs, got %d: %v", len(tt.expectedIDs), len(ids), ids)
238+
}
239+
for i, id := range tt.expectedIDs {
240+
if i >= len(ids) || ids[i] != id {
241+
t.Errorf("Expected rule ID %s at position %d, got %v", id, i, ids)
242+
}
243+
}
244+
245+
if len(instructions) != len(tt.expectedInst) {
246+
t.Errorf("Expected %d instructions, got %d: %v", len(tt.expectedInst), len(instructions), instructions)
247+
}
248+
})
249+
}
250+
}
251+
252+
func TestInstructionResolver_SupersetShadowing(t *testing.T) {
253+
// Rule with more tools (superset) shadows rules with fewer tools
254+
resolver := NewInstructionResolver([]InstructionRule{
255+
NewInstructionRule("issues-read", "Read issues instruction", "get_issue", "list_issues"),
256+
NewInstructionRule("issues-all", "All issues instruction", "get_issue", "list_issues", "create_issue"),
257+
NewInstructionRule("create-only", "Create only instruction", "create_issue"),
258+
})
259+
260+
tests := []struct {
261+
name string
262+
activeTools []string
263+
expectedIDs []string
264+
description string
265+
}{
266+
{
267+
name: "superset rule shadows subset rules",
268+
activeTools: []string{"get_issue", "list_issues", "create_issue"},
269+
expectedIDs: []string{"issues-all"},
270+
description: "issues-all shadows issues-read (superset) and create-only (superset)",
271+
},
272+
{
273+
name: "no shadowing when superset rule doesn't match",
274+
activeTools: []string{"get_issue", "list_issues"},
275+
expectedIDs: []string{"issues-read"},
276+
description: "issues-all doesn't match (missing create_issue), so issues-read is not shadowed",
277+
},
278+
{
279+
name: "single tool rule not shadowed when superset doesn't match",
280+
activeTools: []string{"create_issue"},
281+
expectedIDs: []string{"create-only"},
282+
description: "Only create-only matches",
283+
},
284+
{
285+
name: "extra active tools don't affect shadowing",
286+
activeTools: []string{"get_issue", "list_issues", "create_issue", "get_me", "other_tool"},
287+
expectedIDs: []string{"issues-all"},
288+
description: "Extra tools in activeTools don't prevent shadowing",
289+
},
290+
}
291+
292+
for _, tt := range tests {
293+
t.Run(tt.name, func(t *testing.T) {
294+
ids := resolver.MatchingRuleIDs(tt.activeTools)
295+
296+
if len(ids) != len(tt.expectedIDs) {
297+
t.Errorf("%s: Expected %d rule IDs %v, got %d: %v",
298+
tt.description, len(tt.expectedIDs), tt.expectedIDs, len(ids), ids)
299+
return
300+
}
301+
for i, id := range tt.expectedIDs {
302+
if i >= len(ids) || ids[i] != id {
303+
t.Errorf("%s: Expected rule ID %s at position %d, got %v",
304+
tt.description, id, i, ids)
305+
}
306+
}
307+
})
308+
}
309+
}
310+
311+
func TestInstructionResolver_PartialOverlapNoShadowing(t *testing.T) {
312+
// Rules with partial overlap (neither is superset of other) should both apply
313+
resolver := NewInstructionResolver([]InstructionRule{
314+
NewInstructionRule("rule-ab", "AB instruction", "tool_a", "tool_b"),
315+
NewInstructionRule("rule-bc", "BC instruction", "tool_b", "tool_c"),
316+
})
317+
318+
// When all three tools are active, both rules match
319+
// Neither is a superset of the other, so neither is shadowed
320+
ids := resolver.MatchingRuleIDs([]string{"tool_a", "tool_b", "tool_c"})
321+
322+
if len(ids) != 2 {
323+
t.Errorf("Expected 2 rules (partial overlap, no shadowing), got %d: %v", len(ids), ids)
324+
}
325+
}
326+
327+
func TestInstructionResolver_ComplexHierarchy(t *testing.T) {
328+
// Create a hierarchy: rule1 ⊂ rule2 ⊂ rule3
329+
resolver := NewInstructionResolver([]InstructionRule{
330+
NewInstructionRule("level1", "Level 1 instruction", "t1"),
331+
NewInstructionRule("level2", "Level 2 instruction", "t1", "t2"),
332+
NewInstructionRule("level3", "Level 3 instruction", "t1", "t2", "t3"),
333+
})
334+
335+
tests := []struct {
336+
name string
337+
activeTools []string
338+
expectedIDs []string
339+
}{
340+
{
341+
name: "only level1 active",
342+
activeTools: []string{"t1"},
343+
expectedIDs: []string{"level1"},
344+
},
345+
{
346+
name: "level1 and level2 tools active",
347+
activeTools: []string{"t1", "t2"},
348+
expectedIDs: []string{"level2"}, // shadows level1
349+
},
350+
{
351+
name: "all three levels active",
352+
activeTools: []string{"t1", "t2", "t3"},
353+
expectedIDs: []string{"level3"}, // shadows level1 and level2
354+
},
355+
}
356+
357+
for _, tt := range tests {
358+
t.Run(tt.name, func(t *testing.T) {
359+
ids := resolver.MatchingRuleIDs(tt.activeTools)
360+
if len(ids) != len(tt.expectedIDs) {
361+
t.Errorf("Expected %v, got %v", tt.expectedIDs, ids)
362+
}
363+
})
364+
}
365+
}
366+
367+
func TestInstructionResolver_EmptyRules(t *testing.T) {
368+
resolver := NewInstructionResolver([]InstructionRule{})
369+
370+
ids := resolver.MatchingRuleIDs([]string{"tool1", "tool2"})
371+
if len(ids) != 0 {
372+
t.Errorf("Expected no matches for empty resolver, got %v", ids)
373+
}
374+
375+
instructions := resolver.ResolveInstructions([]string{"tool1"})
376+
if len(instructions) != 0 {
377+
t.Errorf("Expected no instructions for empty resolver, got %v", instructions)
378+
}
379+
}
380+
381+
func TestInstructionResolver_EmptyActiveTools(t *testing.T) {
382+
resolver := NewInstructionResolver([]InstructionRule{
383+
NewInstructionRule("rule1", "Instruction 1", "tool1"),
384+
})
385+
386+
ids := resolver.MatchingRuleIDs([]string{})
387+
if len(ids) != 0 {
388+
t.Errorf("Expected no matches for empty active tools, got %v", ids)
389+
}
390+
}
391+
392+
// =============================================================================
393+
// END SPIKE TESTS
394+
// =============================================================================

0 commit comments

Comments
 (0)