@chigichan24/crune 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +155 -0
- package/bin/crune.js +2 -0
- package/dist-cli/__tests__/cli.test.js +63 -0
- package/dist-cli/__tests__/clustering.test.js +200 -0
- package/dist-cli/__tests__/community.test.js +115 -0
- package/dist-cli/__tests__/edges.test.js +130 -0
- package/dist-cli/__tests__/feature-extraction.test.js +66 -0
- package/dist-cli/__tests__/fixtures.js +192 -0
- package/dist-cli/__tests__/orchestrator.test.js +253 -0
- package/dist-cli/__tests__/session-parser.test.js +335 -0
- package/dist-cli/__tests__/session-summarizer.test.js +117 -0
- package/dist-cli/__tests__/skill-server.test.js +191 -0
- package/dist-cli/__tests__/svd.test.js +112 -0
- package/dist-cli/__tests__/tfidf.test.js +88 -0
- package/dist-cli/__tests__/tokenizer.test.js +125 -0
- package/dist-cli/__tests__/topic-nodes.test.js +184 -0
- package/dist-cli/analyze-sessions.js +476 -0
- package/dist-cli/cli.js +215 -0
- package/dist-cli/knowledge-graph/clustering.js +174 -0
- package/dist-cli/knowledge-graph/community.js +220 -0
- package/dist-cli/knowledge-graph/constants.js +58 -0
- package/dist-cli/knowledge-graph/edges.js +193 -0
- package/dist-cli/knowledge-graph/feature-extraction.js +124 -0
- package/dist-cli/knowledge-graph/index.js +235 -0
- package/dist-cli/knowledge-graph/reusability.js +51 -0
- package/dist-cli/knowledge-graph/similarity.js +13 -0
- package/dist-cli/knowledge-graph/skill-generator.js +203 -0
- package/dist-cli/knowledge-graph/svd.js +195 -0
- package/dist-cli/knowledge-graph/tfidf.js +54 -0
- package/dist-cli/knowledge-graph/tokenizer.js +66 -0
- package/dist-cli/knowledge-graph/tool-pattern.js +173 -0
- package/dist-cli/knowledge-graph/topic-nodes.js +199 -0
- package/dist-cli/knowledge-graph/types.js +4 -0
- package/dist-cli/knowledge-graph-builder.js +27 -0
- package/dist-cli/session-parser.js +360 -0
- package/dist-cli/session-summarizer.js +133 -0
- package/dist-cli/skill-server.js +62 -0
- package/dist-cli/skill-synthesizer.js +189 -0
- package/package.json +47 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateSessionSummary, classifyWorkType, findCommonPathPrefix, } from "../session-summarizer.js";
|
|
3
|
+
describe("generateSessionSummary", () => {
|
|
4
|
+
it("single plan mode prompt: returns that prompt as summary", () => {
|
|
5
|
+
const result = generateSessionSummary([{ userPrompt: "Investigate the login bug", permissionMode: "plan" }], {
|
|
6
|
+
toolBreakdown: { Read: 5 },
|
|
7
|
+
filesEdited: [],
|
|
8
|
+
permissionMode: null,
|
|
9
|
+
turnCount: 1,
|
|
10
|
+
});
|
|
11
|
+
expect(result.summary).toBe("Investigate the login bug");
|
|
12
|
+
});
|
|
13
|
+
it("multiple plan mode prompts: selects the most central one", () => {
|
|
14
|
+
const result = generateSessionSummary([
|
|
15
|
+
{ userPrompt: "refactor authentication module", permissionMode: "plan" },
|
|
16
|
+
{ userPrompt: "refactor authentication tests", permissionMode: "plan" },
|
|
17
|
+
{ userPrompt: "deploy to staging server", permissionMode: "plan" },
|
|
18
|
+
], {
|
|
19
|
+
toolBreakdown: { Edit: 10 },
|
|
20
|
+
filesEdited: [],
|
|
21
|
+
permissionMode: null,
|
|
22
|
+
turnCount: 3,
|
|
23
|
+
});
|
|
24
|
+
// The first two share "refactor" and "authentication", so one of them should be selected
|
|
25
|
+
// The first prompt gets higher position weight, so it should win
|
|
26
|
+
expect(result.summary).toBe("refactor authentication module");
|
|
27
|
+
});
|
|
28
|
+
it("no plan mode prompts: falls back to all user prompts", () => {
|
|
29
|
+
const result = generateSessionSummary([
|
|
30
|
+
{ userPrompt: "fix the build error", permissionMode: "code" },
|
|
31
|
+
{ userPrompt: "run the tests", permissionMode: "code" },
|
|
32
|
+
], {
|
|
33
|
+
toolBreakdown: { Bash: 5 },
|
|
34
|
+
filesEdited: [],
|
|
35
|
+
permissionMode: null,
|
|
36
|
+
turnCount: 2,
|
|
37
|
+
});
|
|
38
|
+
expect(result.summary.length).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
it("empty prompts filtered: skips whitespace-only prompts", () => {
|
|
41
|
+
const result = generateSessionSummary([
|
|
42
|
+
{ userPrompt: " ", permissionMode: "plan" },
|
|
43
|
+
{ userPrompt: "", permissionMode: "plan" },
|
|
44
|
+
{ userPrompt: "implement feature X", permissionMode: "plan" },
|
|
45
|
+
], {
|
|
46
|
+
toolBreakdown: { Edit: 5 },
|
|
47
|
+
filesEdited: [],
|
|
48
|
+
permissionMode: null,
|
|
49
|
+
turnCount: 3,
|
|
50
|
+
});
|
|
51
|
+
expect(result.summary).toBe("implement feature X");
|
|
52
|
+
});
|
|
53
|
+
it("summary truncated to 300 chars: long prompt gets cut", () => {
|
|
54
|
+
const longPrompt = "a".repeat(500);
|
|
55
|
+
const result = generateSessionSummary([{ userPrompt: longPrompt, permissionMode: "plan" }], {
|
|
56
|
+
toolBreakdown: {},
|
|
57
|
+
filesEdited: [],
|
|
58
|
+
permissionMode: null,
|
|
59
|
+
turnCount: 1,
|
|
60
|
+
});
|
|
61
|
+
expect(result.summary.length).toBe(300);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("classifyWorkType", () => {
|
|
65
|
+
it("investigation: high read ratio", () => {
|
|
66
|
+
expect(classifyWorkType({ Read: 10, Grep: 5, Glob: 3, Edit: 1 }, null, 10)).toBe("investigation");
|
|
67
|
+
});
|
|
68
|
+
it("implementation: high write ratio", () => {
|
|
69
|
+
expect(classifyWorkType({ Edit: 10, Write: 3, Read: 5 }, null, 10)).toBe("implementation");
|
|
70
|
+
});
|
|
71
|
+
it("debugging: high bash ratio with some writes", () => {
|
|
72
|
+
expect(classifyWorkType({ Bash: 10, Edit: 3, Read: 2 }, null, 10)).toBe("debugging");
|
|
73
|
+
});
|
|
74
|
+
it("planning: plan mode with few turns and no writes", () => {
|
|
75
|
+
expect(classifyWorkType({ Read: 2 }, "plan", 3)).toBe("planning");
|
|
76
|
+
});
|
|
77
|
+
it("planning: empty tool breakdown with few turns", () => {
|
|
78
|
+
expect(classifyWorkType({}, null, 3)).toBe("planning");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("findCommonPathPrefix", () => {
|
|
82
|
+
it("common prefix: returns shared directory", () => {
|
|
83
|
+
expect(findCommonPathPrefix(["src/a/b.ts", "src/a/c.ts"])).toBe("src/a");
|
|
84
|
+
});
|
|
85
|
+
it("root only: returns empty string", () => {
|
|
86
|
+
expect(findCommonPathPrefix(["src/a.ts", "lib/b.ts"])).toBe("");
|
|
87
|
+
});
|
|
88
|
+
it("single file: returns its directory", () => {
|
|
89
|
+
expect(findCommonPathPrefix(["src/a/b.ts"])).toBe("src/a");
|
|
90
|
+
});
|
|
91
|
+
it("empty array: returns empty string", () => {
|
|
92
|
+
expect(findCommonPathPrefix([])).toBe("");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("keywords extraction", () => {
|
|
96
|
+
it("extracts top keywords from prompts", () => {
|
|
97
|
+
const result = generateSessionSummary([
|
|
98
|
+
{ userPrompt: "refactor authentication module component", permissionMode: "plan" },
|
|
99
|
+
{ userPrompt: "refactor authentication service layer", permissionMode: "plan" },
|
|
100
|
+
{ userPrompt: "refactor authentication controller handler", permissionMode: "plan" },
|
|
101
|
+
], {
|
|
102
|
+
toolBreakdown: { Edit: 10 },
|
|
103
|
+
filesEdited: [],
|
|
104
|
+
permissionMode: null,
|
|
105
|
+
turnCount: 3,
|
|
106
|
+
});
|
|
107
|
+
expect(result.keywords.length).toBeGreaterThan(0);
|
|
108
|
+
expect(result.keywords.length).toBeLessThanOrEqual(5);
|
|
109
|
+
// "refactor" and "authentication" appear in all prompts, should be top keywords
|
|
110
|
+
expect(result.keywords).toContain("refactor");
|
|
111
|
+
expect(result.keywords).toContain("authentication");
|
|
112
|
+
// Stop words should not appear
|
|
113
|
+
for (const kw of result.keywords) {
|
|
114
|
+
expect(kw.trim().length).toBeGreaterThan(0);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildSynthesisPrompt } from "../skill-synthesizer.js";
|
|
3
|
+
// Minimal mock TopicNode
|
|
4
|
+
function makeTopicNode(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: "topic-1",
|
|
7
|
+
label: "Test Topic",
|
|
8
|
+
keywords: ["testing", "mock"],
|
|
9
|
+
dominantRole: "code",
|
|
10
|
+
projects: ["project-a"],
|
|
11
|
+
project: "project-a",
|
|
12
|
+
sessionCount: 5,
|
|
13
|
+
totalDurationMinutes: 120,
|
|
14
|
+
totalToolCalls: 50,
|
|
15
|
+
toolSignature: [{ tool: "Bash", weight: 0.6 }, { tool: "Read", weight: 0.4 }],
|
|
16
|
+
representativePrompts: ["Run tests", "Check output"],
|
|
17
|
+
suggestedPrompt: "Run tests and check output",
|
|
18
|
+
reusabilityScore: { overall: 0.8, frequency: 0.7, timeCost: 0.9, crossProjectScore: 0.5, recency: 0.8 },
|
|
19
|
+
betweennessCentrality: 0.01,
|
|
20
|
+
degreeCentrality: 0.1,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Minimal mock SkillCandidate
|
|
25
|
+
function makeSkillCandidate(overrides = {}) {
|
|
26
|
+
return {
|
|
27
|
+
topicId: "topic-1",
|
|
28
|
+
reusabilityScore: 0.8,
|
|
29
|
+
skillMarkdown: "# Test Skill\nDo the thing.",
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
describe("buildSynthesisPrompt", () => {
|
|
34
|
+
it("should NOT contain Graph Position or Connected Topics without graphContext", () => {
|
|
35
|
+
const prompt = buildSynthesisPrompt({
|
|
36
|
+
skillCandidate: makeSkillCandidate(),
|
|
37
|
+
topicNode: makeTopicNode(),
|
|
38
|
+
});
|
|
39
|
+
expect(prompt).not.toContain("## Graph Position");
|
|
40
|
+
expect(prompt).not.toContain("## Connected Topics");
|
|
41
|
+
});
|
|
42
|
+
it("should contain bridge interpretation for high betweenness centrality", () => {
|
|
43
|
+
const prompt = buildSynthesisPrompt({
|
|
44
|
+
skillCandidate: makeSkillCandidate(),
|
|
45
|
+
topicNode: makeTopicNode({ betweennessCentrality: 0.25 }),
|
|
46
|
+
graphContext: {
|
|
47
|
+
connectedTopics: [],
|
|
48
|
+
isBridgeTopic: true,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
expect(prompt).toContain("## Graph Position");
|
|
52
|
+
expect(prompt).toContain("critical bridge topic connecting multiple knowledge domains");
|
|
53
|
+
expect(prompt).toContain("bridge topic in the knowledge graph");
|
|
54
|
+
});
|
|
55
|
+
it("should contain bridge interpretation for moderate betweenness", () => {
|
|
56
|
+
const prompt = buildSynthesisPrompt({
|
|
57
|
+
skillCandidate: makeSkillCandidate(),
|
|
58
|
+
topicNode: makeTopicNode({ betweennessCentrality: 0.1 }),
|
|
59
|
+
graphContext: {
|
|
60
|
+
connectedTopics: [],
|
|
61
|
+
isBridgeTopic: false,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(prompt).toContain("bridges several knowledge domains");
|
|
65
|
+
});
|
|
66
|
+
it("should contain hub interpretation for high degree centrality", () => {
|
|
67
|
+
const prompt = buildSynthesisPrompt({
|
|
68
|
+
skillCandidate: makeSkillCandidate(),
|
|
69
|
+
topicNode: makeTopicNode({ betweennessCentrality: 0.01, degreeCentrality: 0.6 }),
|
|
70
|
+
graphContext: {
|
|
71
|
+
connectedTopics: [],
|
|
72
|
+
isBridgeTopic: false,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
expect(prompt).toContain("hub topic connected to many other topics");
|
|
76
|
+
});
|
|
77
|
+
it("should contain isolated interpretation for zero degree centrality", () => {
|
|
78
|
+
const prompt = buildSynthesisPrompt({
|
|
79
|
+
skillCandidate: makeSkillCandidate(),
|
|
80
|
+
topicNode: makeTopicNode({ betweennessCentrality: 0.01, degreeCentrality: 0 }),
|
|
81
|
+
graphContext: {
|
|
82
|
+
connectedTopics: [],
|
|
83
|
+
isBridgeTopic: false,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
expect(prompt).toContain("isolated topic with no connections");
|
|
87
|
+
});
|
|
88
|
+
it("should contain peripheral interpretation for low centrality values", () => {
|
|
89
|
+
const prompt = buildSynthesisPrompt({
|
|
90
|
+
skillCandidate: makeSkillCandidate(),
|
|
91
|
+
topicNode: makeTopicNode({ betweennessCentrality: 0.01, degreeCentrality: 0.1 }),
|
|
92
|
+
graphContext: {
|
|
93
|
+
connectedTopics: [],
|
|
94
|
+
isBridgeTopic: false,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(prompt).toContain("peripheral topic");
|
|
98
|
+
});
|
|
99
|
+
it("should contain Prerequisite and Follow-up for workflow-continuation edges", () => {
|
|
100
|
+
const prompt = buildSynthesisPrompt({
|
|
101
|
+
skillCandidate: makeSkillCandidate(),
|
|
102
|
+
topicNode: makeTopicNode(),
|
|
103
|
+
graphContext: {
|
|
104
|
+
connectedTopics: [
|
|
105
|
+
{
|
|
106
|
+
id: "topic-2",
|
|
107
|
+
label: "Setup Environment",
|
|
108
|
+
keywords: ["setup", "env"],
|
|
109
|
+
edgeType: "workflow-continuation",
|
|
110
|
+
strength: 0.85,
|
|
111
|
+
direction: "incoming",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "topic-3",
|
|
115
|
+
label: "Deploy App",
|
|
116
|
+
keywords: ["deploy", "production"],
|
|
117
|
+
edgeType: "workflow-continuation",
|
|
118
|
+
strength: 0.75,
|
|
119
|
+
direction: "outgoing",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
isBridgeTopic: false,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
expect(prompt).toContain("## Connected Topics");
|
|
126
|
+
expect(prompt).toContain("Prerequisite: Setup Environment [setup, env] (strength: 0.85)");
|
|
127
|
+
expect(prompt).toContain("Follow-up: Deploy App [deploy, production] (strength: 0.75)");
|
|
128
|
+
expect(prompt).toContain("requires");
|
|
129
|
+
expect(prompt).toContain("next");
|
|
130
|
+
expect(prompt).toContain("frontmatter");
|
|
131
|
+
});
|
|
132
|
+
it("should contain all edge type groups with mixed edge types", () => {
|
|
133
|
+
const prompt = buildSynthesisPrompt({
|
|
134
|
+
skillCandidate: makeSkillCandidate(),
|
|
135
|
+
topicNode: makeTopicNode(),
|
|
136
|
+
graphContext: {
|
|
137
|
+
connectedTopics: [
|
|
138
|
+
{
|
|
139
|
+
id: "t-wf",
|
|
140
|
+
label: "Workflow Prev",
|
|
141
|
+
keywords: ["wf"],
|
|
142
|
+
edgeType: "workflow-continuation",
|
|
143
|
+
strength: 0.9,
|
|
144
|
+
direction: "incoming",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "t-sm",
|
|
148
|
+
label: "Shared Module Topic",
|
|
149
|
+
keywords: ["shared"],
|
|
150
|
+
edgeType: "shared-module",
|
|
151
|
+
strength: 0.7,
|
|
152
|
+
direction: "outgoing",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "t-cp",
|
|
156
|
+
label: "Cross Project Topic",
|
|
157
|
+
keywords: ["cross"],
|
|
158
|
+
edgeType: "cross-project-bridge",
|
|
159
|
+
strength: 0.6,
|
|
160
|
+
direction: "outgoing",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: "t-ss",
|
|
164
|
+
label: "Similar Topic",
|
|
165
|
+
keywords: ["similar"],
|
|
166
|
+
edgeType: "semantic-similarity",
|
|
167
|
+
strength: 0.5,
|
|
168
|
+
direction: "outgoing",
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
isBridgeTopic: false,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
expect(prompt).toContain("Prerequisite: Workflow Prev [wf] (strength: 0.9)");
|
|
175
|
+
expect(prompt).toContain("Related (shared files): Shared Module Topic [shared]");
|
|
176
|
+
expect(prompt).toContain("Cross-project link: Cross Project Topic [cross]");
|
|
177
|
+
expect(prompt).toContain("Similar topic (differentiate from): Similar Topic [similar]");
|
|
178
|
+
});
|
|
179
|
+
it("should contain community label and member count", () => {
|
|
180
|
+
const prompt = buildSynthesisPrompt({
|
|
181
|
+
skillCandidate: makeSkillCandidate(),
|
|
182
|
+
topicNode: makeTopicNode(),
|
|
183
|
+
graphContext: {
|
|
184
|
+
connectedTopics: [],
|
|
185
|
+
community: { label: "Frontend Development", memberCount: 12 },
|
|
186
|
+
isBridgeTopic: false,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
expect(prompt).toContain("Belongs to community: Frontend Development (12 topics)");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildCombinedMatrix, truncatedSvd, } from "../knowledge-graph-builder.js";
|
|
3
|
+
describe("buildCombinedMatrix", () => {
|
|
4
|
+
it("output has correct dimensions (totalDim = textDim + toolDim + structDim, rows = sessionIds.length)", () => {
|
|
5
|
+
const sessionIds = ["s1", "s2", "s3"];
|
|
6
|
+
const textDim = 4;
|
|
7
|
+
const toolDim = 3;
|
|
8
|
+
const structDim = 2;
|
|
9
|
+
const textVectors = new Map([
|
|
10
|
+
["s1", new Float64Array([1, 0, 0, 1])],
|
|
11
|
+
["s2", new Float64Array([0, 1, 1, 0])],
|
|
12
|
+
["s3", new Float64Array([1, 1, 0, 0])],
|
|
13
|
+
]);
|
|
14
|
+
const toolVectors = new Map([
|
|
15
|
+
["s1", new Float64Array([1, 0, 0])],
|
|
16
|
+
["s2", new Float64Array([0, 1, 0])],
|
|
17
|
+
["s3", new Float64Array([0, 0, 1])],
|
|
18
|
+
]);
|
|
19
|
+
const structVectors = new Map([
|
|
20
|
+
["s1", new Float64Array([1, 0])],
|
|
21
|
+
["s2", new Float64Array([0, 1])],
|
|
22
|
+
["s3", new Float64Array([1, 1])],
|
|
23
|
+
]);
|
|
24
|
+
const { matrix, totalDim } = buildCombinedMatrix(sessionIds, textVectors, toolVectors, structVectors, textDim, toolDim, structDim);
|
|
25
|
+
expect(totalDim).toBe(textDim + toolDim + structDim);
|
|
26
|
+
expect(matrix.length).toBe(sessionIds.length);
|
|
27
|
+
for (const row of matrix) {
|
|
28
|
+
expect(row.length).toBe(totalDim);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it("weights are applied: text portion scaled by sqrt(0.5), tool/struct by sqrt(0.25)", () => {
|
|
32
|
+
const sessionIds = ["s1"];
|
|
33
|
+
const textDim = 2;
|
|
34
|
+
const toolDim = 2;
|
|
35
|
+
const structDim = 2;
|
|
36
|
+
const textVectors = new Map([
|
|
37
|
+
["s1", new Float64Array([1, 2])],
|
|
38
|
+
]);
|
|
39
|
+
const toolVectors = new Map([
|
|
40
|
+
["s1", new Float64Array([3, 4])],
|
|
41
|
+
]);
|
|
42
|
+
const structVectors = new Map([
|
|
43
|
+
["s1", new Float64Array([5, 6])],
|
|
44
|
+
]);
|
|
45
|
+
const { matrix } = buildCombinedMatrix(sessionIds, textVectors, toolVectors, structVectors, textDim, toolDim, structDim);
|
|
46
|
+
const row = matrix[0];
|
|
47
|
+
const sqrtText = Math.sqrt(0.5);
|
|
48
|
+
const sqrtTool = Math.sqrt(0.25);
|
|
49
|
+
const sqrtStruct = Math.sqrt(0.25);
|
|
50
|
+
// Text portion (indices 0-1)
|
|
51
|
+
expect(row[0]).toBeCloseTo(1 * sqrtText);
|
|
52
|
+
expect(row[1]).toBeCloseTo(2 * sqrtText);
|
|
53
|
+
// Tool portion (indices 2-3)
|
|
54
|
+
expect(row[2]).toBeCloseTo(3 * sqrtTool);
|
|
55
|
+
expect(row[3]).toBeCloseTo(4 * sqrtTool);
|
|
56
|
+
// Struct portion (indices 4-5)
|
|
57
|
+
expect(row[4]).toBeCloseTo(5 * sqrtStruct);
|
|
58
|
+
expect(row[5]).toBeCloseTo(6 * sqrtStruct);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("truncatedSvd", () => {
|
|
62
|
+
// Helper: create a test matrix with 5 sessions and 10 features
|
|
63
|
+
function makeTestData() {
|
|
64
|
+
const sessionIds = ["s1", "s2", "s3", "s4", "s5"];
|
|
65
|
+
const totalDim = 10;
|
|
66
|
+
const matrix = [];
|
|
67
|
+
// Create distinct patterns so SVD has structure to find
|
|
68
|
+
const patterns = [
|
|
69
|
+
[1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
|
|
70
|
+
[0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
|
|
71
|
+
[1, 1, 0, 0, 1, 1, 0, 0, 1, 1],
|
|
72
|
+
[0, 0, 1, 1, 0, 0, 1, 1, 0, 0],
|
|
73
|
+
[1, 0.5, 0.5, 0, 1, 0.5, 0.5, 0, 1, 0.5],
|
|
74
|
+
];
|
|
75
|
+
for (const p of patterns) {
|
|
76
|
+
matrix.push(new Float64Array(p));
|
|
77
|
+
}
|
|
78
|
+
return { sessionIds, matrix, totalDim };
|
|
79
|
+
}
|
|
80
|
+
it("5 sessions of 10-dim features, k=3: returns sessionVectors with 3 dimensions", () => {
|
|
81
|
+
const { sessionIds, matrix, totalDim } = makeTestData();
|
|
82
|
+
const result = truncatedSvd(sessionIds, matrix, totalDim, 3);
|
|
83
|
+
expect(result.k).toBe(3);
|
|
84
|
+
expect(result.sigma.length).toBe(3);
|
|
85
|
+
expect(result.sessionVectors.size).toBe(5);
|
|
86
|
+
for (const [, vec] of result.sessionVectors) {
|
|
87
|
+
expect(vec.length).toBe(3);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
it("singular values are non-negative and in descending order", () => {
|
|
91
|
+
const { sessionIds, matrix, totalDim } = makeTestData();
|
|
92
|
+
const result = truncatedSvd(sessionIds, matrix, totalDim, 3);
|
|
93
|
+
for (let i = 0; i < result.sigma.length; i++) {
|
|
94
|
+
expect(result.sigma[i]).toBeGreaterThanOrEqual(0);
|
|
95
|
+
}
|
|
96
|
+
for (let i = 1; i < result.sigma.length; i++) {
|
|
97
|
+
expect(result.sigma[i - 1]).toBeGreaterThanOrEqual(result.sigma[i]);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it("sessionVectors are L2-normalized (magnitude ~= 1.0)", () => {
|
|
101
|
+
const { sessionIds, matrix, totalDim } = makeTestData();
|
|
102
|
+
const result = truncatedSvd(sessionIds, matrix, totalDim, 3);
|
|
103
|
+
for (const [, vec] of result.sessionVectors) {
|
|
104
|
+
let norm = 0;
|
|
105
|
+
for (let i = 0; i < vec.length; i++) {
|
|
106
|
+
norm += vec[i] * vec[i];
|
|
107
|
+
}
|
|
108
|
+
norm = Math.sqrt(norm);
|
|
109
|
+
expect(norm).toBeCloseTo(1.0, 4);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildTfidf } from "../knowledge-graph-builder.js";
|
|
3
|
+
describe("buildTfidf", () => {
|
|
4
|
+
it("excludes terms that appear in only 1 document", () => {
|
|
5
|
+
const documents = new Map();
|
|
6
|
+
documents.set("doc1", ["alpha", "beta", "gamma"]);
|
|
7
|
+
documents.set("doc2", ["alpha", "beta", "delta"]);
|
|
8
|
+
documents.set("doc3", ["alpha", "gamma", "epsilon"]);
|
|
9
|
+
const result = buildTfidf(documents);
|
|
10
|
+
// n=3, maxDf = max(2, floor(3*0.8)) = max(2,2) = 2
|
|
11
|
+
// "alpha" df=3 > maxDf(2) => excluded
|
|
12
|
+
// "beta" df=2, "gamma" df=2 => kept (>=2 and <=2)
|
|
13
|
+
// "delta" df=1, "epsilon" df=1 => excluded (< 2)
|
|
14
|
+
expect(result.vocabulary).toContain("beta");
|
|
15
|
+
expect(result.vocabulary).toContain("gamma");
|
|
16
|
+
expect(result.vocabulary).not.toContain("delta");
|
|
17
|
+
expect(result.vocabulary).not.toContain("epsilon");
|
|
18
|
+
});
|
|
19
|
+
it("excludes terms appearing in >80% of docs when applicable", () => {
|
|
20
|
+
// With 10 docs, maxDf = max(2, floor(10*0.8)) = 8
|
|
21
|
+
const documents = new Map();
|
|
22
|
+
for (let i = 0; i < 10; i++) {
|
|
23
|
+
const tokens = ["ubiquitous"]; // appears in all 10
|
|
24
|
+
if (i < 5)
|
|
25
|
+
tokens.push("common"); // appears in 5 (<=8, >=2) => kept
|
|
26
|
+
if (i < 2)
|
|
27
|
+
tokens.push("rare"); // appears in 2 (>=2, <=8) => kept
|
|
28
|
+
documents.set(`doc${i}`, tokens);
|
|
29
|
+
}
|
|
30
|
+
const result = buildTfidf(documents);
|
|
31
|
+
// "ubiquitous" in 10 docs > maxDf(8) => excluded
|
|
32
|
+
expect(result.vocabulary).not.toContain("ubiquitous");
|
|
33
|
+
// "common" in 5 docs => kept
|
|
34
|
+
expect(result.vocabulary).toContain("common");
|
|
35
|
+
// "rare" in 2 docs => kept
|
|
36
|
+
expect(result.vocabulary).toContain("rare");
|
|
37
|
+
});
|
|
38
|
+
it("produces L2-normalized vectors (dot product with self ~= 1.0)", () => {
|
|
39
|
+
const documents = new Map();
|
|
40
|
+
documents.set("doc1", ["foo", "bar", "baz"]);
|
|
41
|
+
documents.set("doc2", ["foo", "bar", "qux"]);
|
|
42
|
+
documents.set("doc3", ["foo", "baz", "qux"]);
|
|
43
|
+
const result = buildTfidf(documents);
|
|
44
|
+
for (const [, vec] of result.vectors) {
|
|
45
|
+
let dotProduct = 0;
|
|
46
|
+
for (let i = 0; i < vec.length; i++) {
|
|
47
|
+
dotProduct += vec[i] * vec[i];
|
|
48
|
+
}
|
|
49
|
+
// If the vector is non-zero it should be normalized to 1
|
|
50
|
+
if (dotProduct > 0) {
|
|
51
|
+
expect(dotProduct).toBeCloseTo(1.0, 10);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it("gives rare term higher IDF weight than common term", () => {
|
|
56
|
+
const documents = new Map();
|
|
57
|
+
// "common" in 4 of 5 docs, "rare" in 2 of 5 docs
|
|
58
|
+
documents.set("doc1", ["common", "rare"]);
|
|
59
|
+
documents.set("doc2", ["common", "rare"]);
|
|
60
|
+
documents.set("doc3", ["common", "filler"]);
|
|
61
|
+
documents.set("doc4", ["common", "filler"]);
|
|
62
|
+
documents.set("doc5", ["filler", "filler"]);
|
|
63
|
+
const result = buildTfidf(documents);
|
|
64
|
+
// Both "common" (df=4) and "rare" (df=2) should be in vocabulary
|
|
65
|
+
// maxDf = max(2, floor(5*0.8)) = max(2,4) = 4, so common (df=4) is kept
|
|
66
|
+
expect(result.vocabulary).toContain("common");
|
|
67
|
+
expect(result.vocabulary).toContain("rare");
|
|
68
|
+
// Check IDF: log(5/2) > log(5/4) => rare's weight > common's weight
|
|
69
|
+
// Look at doc1 which has both terms once each => TF is same => difference is purely IDF
|
|
70
|
+
const vec = result.vectors.get("doc1");
|
|
71
|
+
const rareIdx = result.vocabIndex.get("rare");
|
|
72
|
+
const commonIdx = result.vocabIndex.get("common");
|
|
73
|
+
// Before normalization, rare would have higher raw value.
|
|
74
|
+
// After L2-normalization, the ratio is preserved, so the rare component should be larger.
|
|
75
|
+
expect(vec[rareIdx]).toBeGreaterThan(vec[commonIdx]);
|
|
76
|
+
});
|
|
77
|
+
it("produces zero vector for empty token list", () => {
|
|
78
|
+
const documents = new Map();
|
|
79
|
+
documents.set("doc1", ["foo", "bar"]);
|
|
80
|
+
documents.set("doc2", ["foo", "bar"]);
|
|
81
|
+
documents.set("doc3", []);
|
|
82
|
+
const result = buildTfidf(documents);
|
|
83
|
+
const vec = result.vectors.get("doc3");
|
|
84
|
+
for (let i = 0; i < vec.length; i++) {
|
|
85
|
+
expect(vec[i]).toBe(0);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { splitCamelCase, extractPathTokens, isNoiseToken, tokenize, } from "../knowledge-graph-builder.js";
|
|
3
|
+
describe("splitCamelCase", () => {
|
|
4
|
+
it("splits camelCase into lowercase parts", () => {
|
|
5
|
+
expect(splitCamelCase("camelCase")).toEqual(["camel", "case"]);
|
|
6
|
+
});
|
|
7
|
+
it("splits PascalCase with uppercase acronym prefix", () => {
|
|
8
|
+
expect(splitCamelCase("XMLParser")).toEqual(["xml", "parser"]);
|
|
9
|
+
});
|
|
10
|
+
it("returns single-element array for lowercase word", () => {
|
|
11
|
+
expect(splitCamelCase("lowercase")).toEqual(["lowercase"]);
|
|
12
|
+
});
|
|
13
|
+
it("splits multiple humps", () => {
|
|
14
|
+
expect(splitCamelCase("myVariableName")).toEqual([
|
|
15
|
+
"my",
|
|
16
|
+
"variable",
|
|
17
|
+
"name",
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
it("handles all-uppercase word", () => {
|
|
21
|
+
const result = splitCamelCase("HTML");
|
|
22
|
+
expect(result).toEqual(["html"]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("extractPathTokens", () => {
|
|
26
|
+
it("extracts tokens from a file path", () => {
|
|
27
|
+
const tokens = extractPathTokens("/src/components/App.tsx");
|
|
28
|
+
expect(tokens).toContain("src");
|
|
29
|
+
expect(tokens).toContain("components");
|
|
30
|
+
expect(tokens).toContain("app");
|
|
31
|
+
// Extension should be stripped
|
|
32
|
+
expect(tokens).not.toContain("tsx");
|
|
33
|
+
});
|
|
34
|
+
it("returns empty array when text has no paths", () => {
|
|
35
|
+
expect(extractPathTokens("just some regular text")).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
it("extracts tokens from multiple paths in text", () => {
|
|
38
|
+
const tokens = extractPathTokens("Edited /src/utils/helpers.ts and /lib/core/Engine.ts");
|
|
39
|
+
expect(tokens).toContain("src");
|
|
40
|
+
expect(tokens).toContain("utils");
|
|
41
|
+
expect(tokens).toContain("helpers");
|
|
42
|
+
expect(tokens).toContain("lib");
|
|
43
|
+
expect(tokens).toContain("core");
|
|
44
|
+
expect(tokens).toContain("engine");
|
|
45
|
+
});
|
|
46
|
+
it("skips short path segments (<=2 chars)", () => {
|
|
47
|
+
const tokens = extractPathTokens("/a/b/component.ts");
|
|
48
|
+
expect(tokens).not.toContain("a");
|
|
49
|
+
expect(tokens).not.toContain("b");
|
|
50
|
+
expect(tokens).toContain("component");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("isNoiseToken", () => {
|
|
54
|
+
it("returns true for UUID strings", () => {
|
|
55
|
+
expect(isNoiseToken("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it("returns true for hex strings of 6+ chars", () => {
|
|
58
|
+
expect(isNoiseToken("abcdef12")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it("returns true for pure numbers", () => {
|
|
61
|
+
expect(isNoiseToken("12345")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it("returns true for extremely long tokens (>40 chars)", () => {
|
|
64
|
+
expect(isNoiseToken("a".repeat(41))).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
it("returns false for normal words", () => {
|
|
67
|
+
expect(isNoiseToken("valid")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it("returns false for short hex-like strings (<6 chars)", () => {
|
|
70
|
+
expect(isNoiseToken("abc")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("tokenize", () => {
|
|
74
|
+
it("tokenizes text with camelCase words", () => {
|
|
75
|
+
const tokens = tokenize("refactor camelCaseFunction");
|
|
76
|
+
expect(tokens).toContain("refactor");
|
|
77
|
+
expect(tokens).toContain("camel");
|
|
78
|
+
expect(tokens).toContain("case");
|
|
79
|
+
expect(tokens).toContain("function");
|
|
80
|
+
});
|
|
81
|
+
it("tokenizes text containing file paths", () => {
|
|
82
|
+
const tokens = tokenize("edited /src/components/App.tsx");
|
|
83
|
+
expect(tokens).toContain("src");
|
|
84
|
+
expect(tokens).toContain("components");
|
|
85
|
+
expect(tokens).toContain("app");
|
|
86
|
+
});
|
|
87
|
+
it("excludes stop words", () => {
|
|
88
|
+
const tokens = tokenize("the quick brown fox in the forest");
|
|
89
|
+
expect(tokens).not.toContain("the");
|
|
90
|
+
expect(tokens).not.toContain("in");
|
|
91
|
+
expect(tokens).toContain("quick");
|
|
92
|
+
expect(tokens).toContain("brown");
|
|
93
|
+
expect(tokens).toContain("fox");
|
|
94
|
+
expect(tokens).toContain("forest");
|
|
95
|
+
});
|
|
96
|
+
it("skips URLs", () => {
|
|
97
|
+
const tokens = tokenize("visit http://example.com/path for details");
|
|
98
|
+
// tokenize skips words starting with "http" but URL parts split by / may remain
|
|
99
|
+
expect(tokens).not.toContain("http");
|
|
100
|
+
expect(tokens).not.toContain("http://example.com/path");
|
|
101
|
+
});
|
|
102
|
+
it("handles kebab-case and snake_case", () => {
|
|
103
|
+
const tokens = tokenize("my-component some_variable");
|
|
104
|
+
expect(tokens).toContain("component");
|
|
105
|
+
// "some" is a stop word, so it's excluded
|
|
106
|
+
expect(tokens).not.toContain("some");
|
|
107
|
+
expect(tokens).toContain("variable");
|
|
108
|
+
});
|
|
109
|
+
it("handles Japanese text without crashing", () => {
|
|
110
|
+
expect(() => tokenize("セッションの分析を実行する")).not.toThrow();
|
|
111
|
+
});
|
|
112
|
+
it("filters out noise tokens", () => {
|
|
113
|
+
const tokens = tokenize("commit 550e8400-e29b-41d4-a716-446655440000 was good");
|
|
114
|
+
expect(tokens).not.toContain("550e8400-e29b-41d4-a716-446655440000");
|
|
115
|
+
expect(tokens).toContain("commit");
|
|
116
|
+
expect(tokens).toContain("good");
|
|
117
|
+
});
|
|
118
|
+
it("filters tokens with 2 or fewer characters", () => {
|
|
119
|
+
const tokens = tokenize("I am ok to go");
|
|
120
|
+
expect(tokens).not.toContain("am");
|
|
121
|
+
expect(tokens).not.toContain("ok");
|
|
122
|
+
expect(tokens).not.toContain("to");
|
|
123
|
+
expect(tokens).not.toContain("go");
|
|
124
|
+
});
|
|
125
|
+
});
|