@mhingston5/lasso 0.1.1 → 0.2.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.
@@ -0,0 +1,285 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ generateFailureModes,
4
+ assessRisks,
5
+ failureModeToRisk,
6
+ probabilityToNumber,
7
+ failureClassToImpact,
8
+ } from "../../src/failures/generator.js";
9
+ import type { FailureMode } from "../../src/failures/generator.js";
10
+ import type { FailureClass } from "../../src/failures/ontology.js";
11
+
12
+ function makeFailureMode(overrides: Partial<FailureMode> = {}): FailureMode {
13
+ return {
14
+ id: "test-mode-1",
15
+ description: "Test failure mode",
16
+ failureClass: "auth",
17
+ probability: "medium",
18
+ triggers: ["trigger A"],
19
+ mitigations: ["mitigation A"],
20
+ recoveryActions: ["recovery A"],
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("probabilityToNumber", () => {
26
+ it("should map low to 0.2", () => {
27
+ expect(probabilityToNumber("low")).toBe(0.2);
28
+ });
29
+
30
+ it("should map medium to 0.5", () => {
31
+ expect(probabilityToNumber("medium")).toBe(0.5);
32
+ });
33
+
34
+ it("should map high to 0.8", () => {
35
+ expect(probabilityToNumber("high")).toBe(0.8);
36
+ });
37
+ });
38
+
39
+ describe("failureClassToImpact", () => {
40
+ it("should map auth to 0.7", () => {
41
+ expect(failureClassToImpact("auth")).toBe(0.7);
42
+ });
43
+
44
+ it("should map network to 0.6", () => {
45
+ expect(failureClassToImpact("network")).toBe(0.6);
46
+ });
47
+
48
+ it("should map resource to 0.5", () => {
49
+ expect(failureClassToImpact("resource")).toBe(0.5);
50
+ });
51
+
52
+ it("should map semantic to 0.4", () => {
53
+ expect(failureClassToImpact("semantic")).toBe(0.4);
54
+ });
55
+
56
+ it("should map tool to 0.6", () => {
57
+ expect(failureClassToImpact("tool")).toBe(0.6);
58
+ });
59
+
60
+ it("should map environment-drift to 0.3", () => {
61
+ expect(failureClassToImpact("environment-drift")).toBe(0.3);
62
+ });
63
+
64
+ it("should map unknown to 0.3", () => {
65
+ expect(failureClassToImpact("unknown")).toBe(0.3);
66
+ });
67
+
68
+ it("should map human to 0.5", () => {
69
+ expect(failureClassToImpact("human")).toBe(0.5);
70
+ });
71
+ });
72
+
73
+ describe("failureModeToRisk", () => {
74
+ it("should convert a FailureMode to a Risk with correct probability", () => {
75
+ const mode = makeFailureMode({ probability: "high" });
76
+ const risk = failureModeToRisk(mode);
77
+
78
+ expect(risk.probability).toBe(0.8);
79
+ });
80
+
81
+ it("should compute score as probability * impact", () => {
82
+ const mode = makeFailureMode({ failureClass: "auth", probability: "high" });
83
+ const risk = failureModeToRisk(mode);
84
+
85
+ expect(risk.score).toBeCloseTo(0.8 * 0.7);
86
+ });
87
+
88
+ it("should map triggers to signals", () => {
89
+ const mode = makeFailureMode({ triggers: ["trigger A", "trigger B"] });
90
+ const risk = failureModeToRisk(mode);
91
+
92
+ expect(risk.signals).toEqual(["trigger A", "trigger B"]);
93
+ });
94
+
95
+ it("should map mitigations to HarnessMutation objects", () => {
96
+ const mode = makeFailureMode({ mitigations: ["mitigation A"] });
97
+ const risk = failureModeToRisk(mode);
98
+
99
+ expect(risk.mitigations).toHaveLength(1);
100
+ expect(risk.mitigations[0].type).toBe("add-verification");
101
+ expect(risk.mitigations[0].description).toBe("mitigation A");
102
+ });
103
+
104
+ it("should preserve failureClass from the FailureMode", () => {
105
+ const mode = makeFailureMode({ failureClass: "network" });
106
+ const risk = failureModeToRisk(mode);
107
+
108
+ expect(risk.failureClass).toBe("network");
109
+ });
110
+
111
+ it("should preserve description from the FailureMode", () => {
112
+ const mode = makeFailureMode({ description: "Something broke" });
113
+ const risk = failureModeToRisk(mode);
114
+
115
+ expect(risk.description).toBe("Something broke");
116
+ });
117
+
118
+ it("should use the FailureMode id as the Risk id", () => {
119
+ const mode = makeFailureMode({ id: "my-mode-42" });
120
+ const risk = failureModeToRisk(mode);
121
+
122
+ expect(risk.id).toBe("my-mode-42");
123
+ });
124
+ });
125
+
126
+ describe("assessRisks", () => {
127
+ it("should return empty risks and 0 overallScore for empty input", () => {
128
+ const assessment = assessRisks([]);
129
+
130
+ expect(assessment.risks).toEqual([]);
131
+ expect(assessment.overallScore).toBe(0);
132
+ expect(assessment.risksAboveThreshold).toEqual([]);
133
+ });
134
+
135
+ it("should compute overallScore as average of risk scores", () => {
136
+ const risks = [
137
+ failureModeToRisk(makeFailureMode({ id: "a", failureClass: "auth", probability: "high" })),
138
+ failureModeToRisk(makeFailureMode({ id: "b", failureClass: "tool", probability: "low" })),
139
+ ];
140
+
141
+ const assessment = assessRisks(risks);
142
+
143
+ expect(assessment.overallScore).toBeCloseTo((0.8 * 0.7 + 0.2 * 0.6) / 2);
144
+ });
145
+
146
+ it("should filter risksAboveThreshold using default threshold 0.7", () => {
147
+ const risks = [
148
+ failureModeToRisk(makeFailureMode({ id: "high-one", failureClass: "auth", probability: "high" })),
149
+ failureModeToRisk(makeFailureMode({ id: "low-one", failureClass: "unknown", probability: "low" })),
150
+ ];
151
+
152
+ const assessment = assessRisks(risks);
153
+
154
+ // auth+high score = 0.8 * 0.7 = 0.56, below 0.7 default threshold
155
+ expect(assessment.risksAboveThreshold).toHaveLength(0);
156
+ });
157
+
158
+ it("should include risks above a custom threshold", () => {
159
+ const risks = [
160
+ failureModeToRisk(makeFailureMode({ id: "high-one", failureClass: "auth", probability: "high" })),
161
+ failureModeToRisk(makeFailureMode({ id: "low-one", failureClass: "unknown", probability: "low" })),
162
+ ];
163
+
164
+ const assessment = assessRisks(risks, { highRiskThreshold: 0.5 });
165
+
166
+ // auth+high = 0.56 > 0.5, unknown+low = 0.06 < 0.5
167
+ expect(assessment.risksAboveThreshold).toHaveLength(1);
168
+ expect(assessment.risksAboveThreshold[0].id).toBe("high-one");
169
+ });
170
+
171
+ it("should respect custom highRiskThreshold", () => {
172
+ const risks = [
173
+ failureModeToRisk(makeFailureMode({ id: "a", failureClass: "auth", probability: "medium" })),
174
+ failureModeToRisk(makeFailureMode({ id: "b", failureClass: "tool", probability: "high" })),
175
+ ];
176
+
177
+ const assessment = assessRisks(risks, { highRiskThreshold: 0.4 });
178
+
179
+ expect(assessment.risksAboveThreshold.length).toBeGreaterThanOrEqual(1);
180
+ });
181
+
182
+ it("should include all risks in the output", () => {
183
+ const risks = [
184
+ failureModeToRisk(makeFailureMode({ id: "a" })),
185
+ failureModeToRisk(makeFailureMode({ id: "b" })),
186
+ failureModeToRisk(makeFailureMode({ id: "c" })),
187
+ ];
188
+
189
+ const assessment = assessRisks(risks);
190
+
191
+ expect(assessment.risks).toHaveLength(3);
192
+ });
193
+
194
+ it("should use default highRiskThreshold of 0.7", () => {
195
+ const assessment = assessRisks([]);
196
+
197
+ expect(assessment.highRiskThreshold).toBe(0.7);
198
+ });
199
+ });
200
+
201
+ describe("generateFailureModes — risks integration", () => {
202
+ it("should include risks array in the result", () => {
203
+ const result = generateFailureModes("deploy application");
204
+
205
+ expect(result.risks).toBeDefined();
206
+ expect(Array.isArray(result.risks)).toBe(true);
207
+ expect(result.risks.length).toBeGreaterThan(0);
208
+ });
209
+
210
+ it("should produce one risk per failure mode", () => {
211
+ const result = generateFailureModes("deploy application");
212
+
213
+ expect(result.risks.length).toBe(result.failureModes.length);
214
+ });
215
+
216
+ it("should produce risks with numeric probability values", () => {
217
+ const result = generateFailureModes("deploy application");
218
+
219
+ for (const risk of result.risks) {
220
+ expect(typeof risk.probability).toBe("number");
221
+ expect(risk.probability).toBeGreaterThanOrEqual(0);
222
+ expect(risk.probability).toBeLessThanOrEqual(1);
223
+ }
224
+ });
225
+
226
+ it("should produce risks with numeric impact values", () => {
227
+ const result = generateFailureModes("deploy application");
228
+
229
+ for (const risk of result.risks) {
230
+ expect(typeof risk.impact).toBe("number");
231
+ expect(risk.impact).toBeGreaterThanOrEqual(0);
232
+ expect(risk.impact).toBeLessThanOrEqual(1);
233
+ }
234
+ });
235
+
236
+ it("should produce risks with score = probability * impact", () => {
237
+ const result = generateFailureModes("deploy application");
238
+
239
+ for (const risk of result.risks) {
240
+ expect(risk.score).toBeCloseTo(risk.probability * risk.impact);
241
+ }
242
+ });
243
+
244
+ it("should produce risks with HarnessMutation mitigations", () => {
245
+ const result = generateFailureModes("deploy application");
246
+
247
+ for (const risk of result.risks) {
248
+ expect(Array.isArray(risk.mitigations)).toBe(true);
249
+ for (const m of risk.mitigations) {
250
+ expect(m.type).toBeDefined();
251
+ expect(typeof m.type).toBe("string");
252
+ }
253
+ }
254
+ });
255
+
256
+ it("should produce risks with signals from triggers", () => {
257
+ const result = generateFailureModes("deploy application");
258
+
259
+ for (const risk of result.risks) {
260
+ expect(Array.isArray(risk.signals)).toBe(true);
261
+ expect(risk.signals.length).toBeGreaterThan(0);
262
+ }
263
+ });
264
+
265
+ it("should produce risks matching failure classes", () => {
266
+ const result = generateFailureModes("deploy application");
267
+
268
+ for (const risk of result.risks) {
269
+ expect(typeof risk.failureClass).toBe("string");
270
+ }
271
+ });
272
+
273
+ it("should remain backwards-compatible — failureModes still has original shape", () => {
274
+ const result = generateFailureModes("deploy application");
275
+
276
+ for (const mode of result.failureModes) {
277
+ expect(typeof mode.id).toBe("string");
278
+ expect(typeof mode.description).toBe("string");
279
+ expect(["low", "medium", "high"]).toContain(mode.probability);
280
+ expect(Array.isArray(mode.triggers)).toBe(true);
281
+ expect(Array.isArray(mode.mitigations)).toBe(true);
282
+ expect(Array.isArray(mode.recoveryActions)).toBe(true);
283
+ }
284
+ });
285
+ });
@@ -0,0 +1,372 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DefaultMetaHarness } from "../../src/metaharness/engine.js";
3
+ import type {
4
+ ExecutionTrace,
5
+ CompletedNode,
6
+ FailedNode,
7
+ MetaHarnessConfig,
8
+ } from "../../src/metaharness/types.js";
9
+ import type { HarnessSpec } from "../../src/spec/types.js";
10
+ import type { EnvironmentModel } from "../../src/environment/types.js";
11
+
12
+ function makeMinimalSpec(overrides?: Partial<HarnessSpec>): HarnessSpec {
13
+ return {
14
+ name: "test-harness",
15
+ graph: {
16
+ entryNodeId: "node-a",
17
+ nodes: [
18
+ {
19
+ id: "node-a",
20
+ label: "Node A",
21
+ kind: "tool",
22
+ tool: "bash",
23
+ args: ["echo hello"],
24
+ },
25
+ {
26
+ id: "node-b",
27
+ label: "Node B",
28
+ kind: "tool",
29
+ tool: "bash",
30
+ args: ["echo world"],
31
+ },
32
+ ],
33
+ edges: [{ from: "node-a", to: "node-b" }],
34
+ },
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function makeMinimalEnv(overrides?: Partial<EnvironmentModel>): EnvironmentModel {
40
+ return {
41
+ tools: [
42
+ { name: "bash", version: "5.0", available: true },
43
+ { name: "git", version: "2.39", available: true },
44
+ ],
45
+ resources: [
46
+ { name: "disk", type: "disk", available: true, limit: "500GB", usage: "45%" },
47
+ ],
48
+ constraints: [],
49
+ authState: [],
50
+ externalSystems: [],
51
+ discoveredAt: Date.now(),
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ function makeCompletedNode(overrides: Partial<CompletedNode>): CompletedNode {
57
+ return {
58
+ nodeId: "node-a",
59
+ startedAt: Date.now() - 1000,
60
+ completedAt: Date.now(),
61
+ output: "ok",
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ function makeFailedNode(overrides: Partial<FailedNode>): FailedNode {
67
+ return {
68
+ nodeId: "node-a",
69
+ startedAt: Date.now() - 1000,
70
+ failedAt: Date.now(),
71
+ error: "command not found",
72
+ retryCount: 0,
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ function makeTrace(overrides?: Partial<ExecutionTrace>): ExecutionTrace {
78
+ return {
79
+ completedNodes: [],
80
+ failedNodes: [],
81
+ capturedAt: Date.now(),
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ describe("synthesizeFromTrace", () => {
87
+ const harness = new DefaultMetaHarness({});
88
+ const env = makeMinimalEnv();
89
+ const spec = makeMinimalSpec();
90
+
91
+ describe("empty trace", () => {
92
+ it("returns spec unchanged with no mutations when trace is empty", async () => {
93
+ const trace = makeTrace();
94
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
95
+
96
+ expect(result.mutations).toHaveLength(0);
97
+ expect(result.spec).toEqual(spec);
98
+ expect(result.rationale).toHaveLength(0);
99
+ expect(result.decision).toBe("continue");
100
+ });
101
+ });
102
+
103
+ describe("completed nodes only", () => {
104
+ it("returns continue decision when all nodes completed successfully", async () => {
105
+ const trace = makeTrace({
106
+ completedNodes: [
107
+ makeCompletedNode({ nodeId: "node-a" }),
108
+ makeCompletedNode({ nodeId: "node-b" }),
109
+ ],
110
+ });
111
+
112
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
113
+
114
+ expect(result.decision).toBe("continue");
115
+ expect(result.mutations).toHaveLength(0);
116
+ expect(result.rationale).toHaveLength(0);
117
+ });
118
+ });
119
+
120
+ describe("failure analysis", () => {
121
+ it("classifies failures and derives mutations for tool failures", async () => {
122
+ const trace = makeTrace({
123
+ failedNodes: [
124
+ makeFailedNode({
125
+ nodeId: "node-a",
126
+ error: "bash: command not found: kubectl",
127
+ retryCount: 0,
128
+ }),
129
+ ],
130
+ });
131
+
132
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
133
+
134
+ expect(result.mutations.length).toBeGreaterThan(0);
135
+ expect(result.rationale.length).toBeGreaterThan(0);
136
+ });
137
+
138
+ it("adds retry policy for transient network failures", async () => {
139
+ const trace = makeTrace({
140
+ failedNodes: [
141
+ makeFailedNode({
142
+ nodeId: "node-a",
143
+ error: "ETIMEDOUT connection timeout",
144
+ retryCount: 0,
145
+ }),
146
+ ],
147
+ });
148
+
149
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
150
+
151
+ const retryMutations = result.mutations.filter(m => m.type === "modify-node");
152
+ expect(retryMutations.length).toBeGreaterThan(0);
153
+ });
154
+
155
+ it("detects repeated failures on same node", async () => {
156
+ const trace = makeTrace({
157
+ failedNodes: [
158
+ makeFailedNode({
159
+ nodeId: "node-a",
160
+ error: "connection refused",
161
+ retryCount: 2,
162
+ failedAt: Date.now() - 2000,
163
+ }),
164
+ makeFailedNode({
165
+ nodeId: "node-a",
166
+ error: "connection refused",
167
+ retryCount: 3,
168
+ failedAt: Date.now(),
169
+ }),
170
+ ],
171
+ });
172
+
173
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
174
+
175
+ expect(result.rationale.some(r => r.includes("repeated") || r.includes("node-a"))).toBe(true);
176
+ });
177
+
178
+ it("detects slow nodes", async () => {
179
+ const now = Date.now();
180
+ const trace = makeTrace({
181
+ completedNodes: [
182
+ makeCompletedNode({
183
+ nodeId: "node-a",
184
+ startedAt: now - 60000,
185
+ completedAt: now,
186
+ }),
187
+ ],
188
+ });
189
+
190
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
191
+
192
+ expect(result.rationale.some(r => r.includes("slow") || r.includes("duration"))).toBe(true);
193
+ });
194
+
195
+ it("detects cost spikes", async () => {
196
+ const trace = makeTrace({
197
+ completedNodes: [
198
+ makeCompletedNode({ nodeId: "node-a", costUsd: 5.0 }),
199
+ makeCompletedNode({ nodeId: "node-b", costUsd: 3.0 }),
200
+ ],
201
+ totalCostUsd: 8.0,
202
+ });
203
+
204
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
205
+
206
+ expect(result.rationale.some(r => r.includes("cost") || r.includes("budget"))).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe("decision logic", () => {
211
+ it("returns stop when all nodes failed", async () => {
212
+ const trace = makeTrace({
213
+ failedNodes: [
214
+ makeFailedNode({ nodeId: "node-a", error: "auth required" }),
215
+ makeFailedNode({ nodeId: "node-b", error: "auth required" }),
216
+ ],
217
+ });
218
+
219
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
220
+
221
+ expect(result.decision).toBe("stop");
222
+ });
223
+
224
+ it("returns needs_operator_input for human-required failures", async () => {
225
+ const trace = makeTrace({
226
+ failedNodes: [
227
+ makeFailedNode({
228
+ nodeId: "node-a",
229
+ error: "approval required by operator",
230
+ retryCount: 0,
231
+ }),
232
+ ],
233
+ });
234
+
235
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
236
+
237
+ expect(result.decision).toBe("needs_operator_input");
238
+ });
239
+
240
+ it("returns continue when failures are recoverable", async () => {
241
+ const trace = makeTrace({
242
+ completedNodes: [
243
+ makeCompletedNode({ nodeId: "node-a" }),
244
+ ],
245
+ failedNodes: [
246
+ makeFailedNode({
247
+ nodeId: "node-b",
248
+ error: "ETIMEDOUT",
249
+ retryCount: 0,
250
+ }),
251
+ ],
252
+ });
253
+
254
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
255
+
256
+ expect(result.decision).toBe("continue");
257
+ });
258
+ });
259
+
260
+ describe("mutation application", () => {
261
+ it("applies mutations to spec and returns updated spec", async () => {
262
+ const trace = makeTrace({
263
+ failedNodes: [
264
+ makeFailedNode({
265
+ nodeId: "node-a",
266
+ error: "connection timeout",
267
+ retryCount: 0,
268
+ }),
269
+ ],
270
+ });
271
+
272
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
273
+
274
+ expect(result.spec).not.toEqual(spec);
275
+ expect(result.spec.graph.nodes.length).toBeGreaterThanOrEqual(spec.graph.nodes.length);
276
+ });
277
+
278
+ it("does not mutate original spec", async () => {
279
+ const originalSpec = makeMinimalSpec();
280
+ const trace = makeTrace({
281
+ failedNodes: [
282
+ makeFailedNode({ nodeId: "node-a", error: "timeout" }),
283
+ ],
284
+ });
285
+
286
+ await harness.synthesizeFromTrace(trace, originalSpec, env);
287
+
288
+ expect(originalSpec.graph.nodes.length).toBe(2);
289
+ });
290
+ });
291
+
292
+ describe("mutation policy enforcement", () => {
293
+ it("respects mutation policy when provided", async () => {
294
+ const config: MetaHarnessConfig = {
295
+ mutationPolicy: {
296
+ allowedMutations: ["modify-node"],
297
+ maxMutations: 1,
298
+ },
299
+ };
300
+ const harnessWithPolicy = new DefaultMetaHarness(config);
301
+ const trace = makeTrace({
302
+ failedNodes: [
303
+ makeFailedNode({
304
+ nodeId: "node-a",
305
+ error: "ETIMEDOUT connection timeout",
306
+ retryCount: 0,
307
+ }),
308
+ makeFailedNode({
309
+ nodeId: "node-b",
310
+ error: "ETIMEDOUT connection timeout",
311
+ retryCount: 0,
312
+ }),
313
+ ],
314
+ });
315
+
316
+ const result = await harnessWithPolicy.synthesizeFromTrace(trace, spec, env);
317
+
318
+ expect(result.mutations.length).toBeLessThanOrEqual(1);
319
+ });
320
+ });
321
+
322
+ describe("multiple failure patterns", () => {
323
+ it("handles mixed completed and failed nodes", async () => {
324
+ const trace = makeTrace({
325
+ completedNodes: [
326
+ makeCompletedNode({ nodeId: "node-a", costUsd: 0.5 }),
327
+ ],
328
+ failedNodes: [
329
+ makeFailedNode({
330
+ nodeId: "node-b",
331
+ error: "permission denied",
332
+ retryCount: 2,
333
+ }),
334
+ ],
335
+ totalCostUsd: 0.5,
336
+ });
337
+
338
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
339
+
340
+ expect(result.mutations.length).toBeGreaterThan(0);
341
+ expect(result.rationale.length).toBeGreaterThan(0);
342
+ expect(["continue", "needs_operator_input", "stop"]).toContain(result.decision);
343
+ });
344
+
345
+ it("derives trace-based mutations when entries are populated via adapter", async () => {
346
+ const trace = makeTrace({
347
+ failedNodes: [
348
+ makeFailedNode({
349
+ nodeId: "node-a",
350
+ error: "connection refused",
351
+ retryCount: 3,
352
+ }),
353
+ makeFailedNode({
354
+ nodeId: "node-a",
355
+ error: "connection refused",
356
+ retryCount: 4,
357
+ }),
358
+ makeFailedNode({
359
+ nodeId: "node-a",
360
+ error: "connection refused",
361
+ retryCount: 5,
362
+ }),
363
+ ],
364
+ });
365
+
366
+ const result = await harness.synthesizeFromTrace(trace, spec, env);
367
+
368
+ expect(result.rationale.some(r => r.includes("trace"))).toBe(true);
369
+ expect(result.mutations.length).toBeGreaterThan(0);
370
+ });
371
+ });
372
+ });