opencode-swarm-plugin 0.14.0 → 0.16.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/src/learning.ts CHANGED
@@ -524,6 +524,203 @@ export class InMemoryFeedbackStorage implements FeedbackStorage {
524
524
  }
525
525
  }
526
526
 
527
+ // ============================================================================
528
+ // 3-Strike Detection
529
+ // ============================================================================
530
+
531
+ /**
532
+ * Strike record for a bead
533
+ *
534
+ * Tracks consecutive fix failures to detect architectural problems.
535
+ * After 3 strikes, the system should STOP and question the architecture
536
+ * rather than attempting Fix #4.
537
+ */
538
+ export const StrikeRecordSchema = z.object({
539
+ /** The bead ID */
540
+ bead_id: z.string(),
541
+ /** Number of consecutive failures */
542
+ strike_count: z.number().int().min(0).max(3),
543
+ /** Failure descriptions for each strike */
544
+ failures: z.array(
545
+ z.object({
546
+ /** What fix was attempted */
547
+ attempt: z.string(),
548
+ /** Why it failed */
549
+ reason: z.string(),
550
+ /** When it failed */
551
+ timestamp: z.string(), // ISO-8601
552
+ }),
553
+ ),
554
+ /** When strikes were recorded */
555
+ first_strike_at: z.string().optional(), // ISO-8601
556
+ last_strike_at: z.string().optional(), // ISO-8601
557
+ });
558
+ export type StrikeRecord = z.infer<typeof StrikeRecordSchema>;
559
+
560
+ /**
561
+ * Storage interface for strike records
562
+ */
563
+ export interface StrikeStorage {
564
+ /** Store a strike record */
565
+ store(record: StrikeRecord): Promise<void>;
566
+ /** Get strike record for a bead */
567
+ get(beadId: string): Promise<StrikeRecord | null>;
568
+ /** Get all strike records */
569
+ getAll(): Promise<StrikeRecord[]>;
570
+ /** Clear strikes for a bead */
571
+ clear(beadId: string): Promise<void>;
572
+ }
573
+
574
+ /**
575
+ * In-memory strike storage
576
+ */
577
+ export class InMemoryStrikeStorage implements StrikeStorage {
578
+ private strikes: Map<string, StrikeRecord> = new Map();
579
+
580
+ async store(record: StrikeRecord): Promise<void> {
581
+ this.strikes.set(record.bead_id, record);
582
+ }
583
+
584
+ async get(beadId: string): Promise<StrikeRecord | null> {
585
+ return this.strikes.get(beadId) ?? null;
586
+ }
587
+
588
+ async getAll(): Promise<StrikeRecord[]> {
589
+ return Array.from(this.strikes.values());
590
+ }
591
+
592
+ async clear(beadId: string): Promise<void> {
593
+ this.strikes.delete(beadId);
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Add a strike to a bead's record
599
+ *
600
+ * Records a failure attempt and increments the strike count.
601
+ *
602
+ * @param beadId - Bead ID
603
+ * @param attempt - Description of what was attempted
604
+ * @param reason - Why it failed
605
+ * @param storage - Strike storage (defaults to in-memory)
606
+ * @returns Updated strike record
607
+ */
608
+ export async function addStrike(
609
+ beadId: string,
610
+ attempt: string,
611
+ reason: string,
612
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
613
+ ): Promise<StrikeRecord> {
614
+ const existing = await storage.get(beadId);
615
+ const now = new Date().toISOString();
616
+
617
+ const record: StrikeRecord = existing ?? {
618
+ bead_id: beadId,
619
+ strike_count: 0,
620
+ failures: [],
621
+ };
622
+
623
+ record.strike_count = Math.min(3, record.strike_count + 1);
624
+ record.failures.push({ attempt, reason, timestamp: now });
625
+ record.last_strike_at = now;
626
+
627
+ if (!record.first_strike_at) {
628
+ record.first_strike_at = now;
629
+ }
630
+
631
+ await storage.store(record);
632
+ return record;
633
+ }
634
+
635
+ /**
636
+ * Get strike count for a bead
637
+ *
638
+ * @param beadId - Bead ID
639
+ * @param storage - Strike storage
640
+ * @returns Strike count (0-3)
641
+ */
642
+ export async function getStrikes(
643
+ beadId: string,
644
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
645
+ ): Promise<number> {
646
+ const record = await storage.get(beadId);
647
+ return record?.strike_count ?? 0;
648
+ }
649
+
650
+ /**
651
+ * Check if a bead has struck out (3 strikes)
652
+ *
653
+ * @param beadId - Bead ID
654
+ * @param storage - Strike storage
655
+ * @returns True if bead has 3 strikes
656
+ */
657
+ export async function isStrikedOut(
658
+ beadId: string,
659
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
660
+ ): Promise<boolean> {
661
+ const count = await getStrikes(beadId, storage);
662
+ return count >= 3;
663
+ }
664
+
665
+ /**
666
+ * Generate architecture review prompt for a struck-out bead
667
+ *
668
+ * When a bead hits 3 strikes, this generates a prompt that forces
669
+ * the human to question the architecture instead of attempting Fix #4.
670
+ *
671
+ * @param beadId - Bead ID
672
+ * @param storage - Strike storage
673
+ * @returns Architecture review prompt
674
+ */
675
+ export async function getArchitecturePrompt(
676
+ beadId: string,
677
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
678
+ ): Promise<string> {
679
+ const record = await storage.get(beadId);
680
+
681
+ if (!record || record.strike_count < 3) {
682
+ return "";
683
+ }
684
+
685
+ const failuresList = record.failures
686
+ .map((f, i) => `${i + 1}. **${f.attempt}** - Failed: ${f.reason}`)
687
+ .join("\n");
688
+
689
+ return `## Architecture Review Required
690
+
691
+ This bead (\`${beadId}\`) has failed 3 consecutive fix attempts:
692
+
693
+ ${failuresList}
694
+
695
+ This pattern suggests an **architectural problem**, not a bug.
696
+
697
+ **Questions to consider:**
698
+ - Is the current approach fundamentally sound?
699
+ - Should we refactor the architecture instead?
700
+ - Are we fixing symptoms instead of root cause?
701
+
702
+ **Options:**
703
+ 1. **Refactor architecture** (describe new approach)
704
+ 2. **Continue with Fix #4** (explain why this time is different)
705
+ 3. **Abandon this approach entirely**
706
+
707
+ **DO NOT attempt Fix #4 without answering these questions.**
708
+ `;
709
+ }
710
+
711
+ /**
712
+ * Clear strikes for a bead (e.g., after successful fix)
713
+ *
714
+ * @param beadId - Bead ID
715
+ * @param storage - Strike storage
716
+ */
717
+ export async function clearStrikes(
718
+ beadId: string,
719
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
720
+ ): Promise<void> {
721
+ await storage.clear(beadId);
722
+ }
723
+
527
724
  // ============================================================================
528
725
  // Error Accumulator
529
726
  // ============================================================================
@@ -772,4 +969,5 @@ export const learningSchemas = {
772
969
  DecompositionStrategySchema,
773
970
  ErrorTypeSchema,
774
971
  ErrorEntrySchema,
972
+ StrikeRecordSchema,
775
973
  };
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Tests for mandate promotion engine
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ evaluateBatchPromotions,
8
+ evaluatePromotion,
9
+ formatPromotionResult,
10
+ getStatusChanges,
11
+ groupByTransition,
12
+ shouldPromote,
13
+ } from "./mandate-promotion";
14
+ import { DEFAULT_MANDATE_DECAY_CONFIG } from "./schemas/mandate";
15
+ import type { MandateEntry, MandateScore } from "./schemas/mandate";
16
+
17
+ // ============================================================================
18
+ // Test Helpers
19
+ // ============================================================================
20
+
21
+ function createMockEntry(
22
+ id: string,
23
+ status: "candidate" | "established" | "mandate" | "rejected" = "candidate",
24
+ ): MandateEntry {
25
+ return {
26
+ id,
27
+ content: "Test mandate content",
28
+ content_type: "idea",
29
+ author_agent: "TestAgent",
30
+ created_at: new Date().toISOString(),
31
+ status,
32
+ tags: [],
33
+ };
34
+ }
35
+
36
+ function createMockScore(
37
+ mandate_id: string,
38
+ net_votes: number,
39
+ vote_ratio: number,
40
+ raw_upvotes: number = Math.max(0, net_votes),
41
+ raw_downvotes: number = 0,
42
+ ): MandateScore {
43
+ return {
44
+ mandate_id,
45
+ net_votes,
46
+ vote_ratio,
47
+ decayed_score: net_votes * vote_ratio,
48
+ last_calculated: new Date().toISOString(),
49
+ raw_upvotes,
50
+ raw_downvotes,
51
+ decayed_upvotes: raw_upvotes,
52
+ decayed_downvotes: raw_downvotes,
53
+ };
54
+ }
55
+
56
+ // ============================================================================
57
+ // shouldPromote Tests
58
+ // ============================================================================
59
+
60
+ describe("shouldPromote", () => {
61
+ it("candidate stays candidate with insufficient votes", () => {
62
+ const score = createMockScore("m1", 1, 1.0);
63
+ const result = shouldPromote(score, "candidate");
64
+ expect(result).toBe("candidate");
65
+ });
66
+
67
+ it("candidate → established at threshold (net_votes >= 2)", () => {
68
+ const score = createMockScore("m1", 2, 1.0);
69
+ const result = shouldPromote(score, "candidate");
70
+ expect(result).toBe("established");
71
+ });
72
+
73
+ it("candidate → established above threshold", () => {
74
+ const score = createMockScore("m1", 3, 0.9);
75
+ const result = shouldPromote(score, "candidate");
76
+ expect(result).toBe("established");
77
+ });
78
+
79
+ it("established stays established with insufficient mandate votes", () => {
80
+ const score = createMockScore("m1", 3, 0.8);
81
+ const result = shouldPromote(score, "established");
82
+ expect(result).toBe("established");
83
+ });
84
+
85
+ it("established stays established with low vote ratio", () => {
86
+ const score = createMockScore("m1", 6, 0.6); // net_votes OK, but ratio < 0.7
87
+ const result = shouldPromote(score, "established");
88
+ expect(result).toBe("established");
89
+ });
90
+
91
+ it("established → mandate at threshold (net >= 5, ratio >= 0.7)", () => {
92
+ const score = createMockScore("m1", 5, 0.7);
93
+ const result = shouldPromote(score, "established");
94
+ expect(result).toBe("mandate");
95
+ });
96
+
97
+ it("established → mandate above threshold", () => {
98
+ const score = createMockScore("m1", 10, 0.9);
99
+ const result = shouldPromote(score, "established");
100
+ expect(result).toBe("mandate");
101
+ });
102
+
103
+ it("mandate stays mandate (no demotion)", () => {
104
+ const score = createMockScore("m1", 3, 0.5); // Degraded score
105
+ const result = shouldPromote(score, "mandate");
106
+ expect(result).toBe("mandate");
107
+ });
108
+
109
+ it("candidate → rejected with negative votes", () => {
110
+ const score = createMockScore("m1", -3, 0.2);
111
+ const result = shouldPromote(score, "candidate");
112
+ expect(result).toBe("rejected");
113
+ });
114
+
115
+ it("established → rejected with negative votes", () => {
116
+ const score = createMockScore("m1", -4, 0.1);
117
+ const result = shouldPromote(score, "established");
118
+ expect(result).toBe("rejected");
119
+ });
120
+
121
+ it("rejected stays rejected (permanent)", () => {
122
+ const score = createMockScore("m1", 5, 0.9); // Even with good score
123
+ const result = shouldPromote(score, "rejected");
124
+ expect(result).toBe("rejected");
125
+ });
126
+
127
+ it("uses custom config thresholds", () => {
128
+ const score = createMockScore("m1", 3, 0.6);
129
+ const customConfig = {
130
+ ...DEFAULT_MANDATE_DECAY_CONFIG,
131
+ establishedNetVotesThreshold: 3,
132
+ mandateNetVotesThreshold: 3,
133
+ mandateVoteRatioThreshold: 0.6,
134
+ };
135
+ const result = shouldPromote(score, "candidate", customConfig);
136
+ expect(result).toBe("established");
137
+
138
+ const result2 = shouldPromote(score, "established", customConfig);
139
+ expect(result2).toBe("mandate");
140
+ });
141
+ });
142
+
143
+ // ============================================================================
144
+ // evaluatePromotion Tests
145
+ // ============================================================================
146
+
147
+ describe("evaluatePromotion", () => {
148
+ it("returns correct promotion result for candidate → established", () => {
149
+ const entry = createMockEntry("m1", "candidate");
150
+ const score = createMockScore("m1", 2, 1.0);
151
+ const result = evaluatePromotion(entry, score);
152
+
153
+ expect(result.mandate_id).toBe("m1");
154
+ expect(result.previous_status).toBe("candidate");
155
+ expect(result.new_status).toBe("established");
156
+ expect(result.promoted).toBe(true);
157
+ expect(result.reason).toContain("Promoted to established");
158
+ expect(result.score).toEqual(score);
159
+ });
160
+
161
+ it("returns correct promotion result for established → mandate", () => {
162
+ const entry = createMockEntry("m1", "established");
163
+ const score = createMockScore("m1", 5, 0.7);
164
+ const result = evaluatePromotion(entry, score);
165
+
166
+ expect(result.mandate_id).toBe("m1");
167
+ expect(result.previous_status).toBe("established");
168
+ expect(result.new_status).toBe("mandate");
169
+ expect(result.promoted).toBe(true);
170
+ expect(result.reason).toContain("Promoted to mandate");
171
+ });
172
+
173
+ it("returns correct promotion result for candidate → rejected", () => {
174
+ const entry = createMockEntry("m1", "candidate");
175
+ const score = createMockScore("m1", -3, 0.2);
176
+ const result = evaluatePromotion(entry, score);
177
+
178
+ expect(result.mandate_id).toBe("m1");
179
+ expect(result.previous_status).toBe("candidate");
180
+ expect(result.new_status).toBe("rejected");
181
+ expect(result.promoted).toBe(true);
182
+ expect(result.reason).toContain("Rejected due to negative consensus");
183
+ });
184
+
185
+ it("returns correct result for no status change", () => {
186
+ const entry = createMockEntry("m1", "candidate");
187
+ const score = createMockScore("m1", 1, 0.8);
188
+ const result = evaluatePromotion(entry, score);
189
+
190
+ expect(result.mandate_id).toBe("m1");
191
+ expect(result.previous_status).toBe("candidate");
192
+ expect(result.new_status).toBe("candidate");
193
+ expect(result.promoted).toBe(false);
194
+ expect(result.reason).toContain("Remains candidate");
195
+ });
196
+
197
+ it("returns correct result for mandate staying mandate", () => {
198
+ const entry = createMockEntry("m1", "mandate");
199
+ const score = createMockScore("m1", 3, 0.5); // Degraded
200
+ const result = evaluatePromotion(entry, score);
201
+
202
+ expect(result.mandate_id).toBe("m1");
203
+ expect(result.previous_status).toBe("mandate");
204
+ expect(result.new_status).toBe("mandate");
205
+ expect(result.promoted).toBe(false);
206
+ expect(result.reason).toContain("Remains mandate");
207
+ });
208
+
209
+ it("returns correct result for rejected staying rejected", () => {
210
+ const entry = createMockEntry("m1", "rejected");
211
+ const score = createMockScore("m1", 10, 0.95); // Good score
212
+ const result = evaluatePromotion(entry, score);
213
+
214
+ expect(result.mandate_id).toBe("m1");
215
+ expect(result.previous_status).toBe("rejected");
216
+ expect(result.new_status).toBe("rejected");
217
+ expect(result.promoted).toBe(false);
218
+ expect(result.reason).toContain("Remains rejected");
219
+ });
220
+ });
221
+
222
+ // ============================================================================
223
+ // Decay Effect Tests
224
+ // ============================================================================
225
+
226
+ describe("decay affects promotion timing", () => {
227
+ it("net_votes can decay below promotion threshold", () => {
228
+ // Entry was established with 2.5 decayed net_votes
229
+ const entry = createMockEntry("m1", "established");
230
+ const score = createMockScore("m1", 1.5, 0.8); // Decayed below threshold
231
+ const result = evaluatePromotion(entry, score);
232
+
233
+ // Stays established (no demotion) even though decayed below candidate→established threshold
234
+ expect(result.new_status).toBe("established");
235
+ expect(result.promoted).toBe(false);
236
+ });
237
+
238
+ it("vote_ratio decay prevents mandate promotion", () => {
239
+ const entry = createMockEntry("m1", "established");
240
+ // High net_votes but low ratio due to decay
241
+ const score = createMockScore("m1", 6, 0.65, 10, 4); // ratio < 0.7
242
+ const result = evaluatePromotion(entry, score);
243
+
244
+ expect(result.new_status).toBe("established");
245
+ expect(result.promoted).toBe(false);
246
+ expect(result.reason).toContain("below mandate threshold");
247
+ });
248
+
249
+ it("fresh votes can push over mandate threshold", () => {
250
+ const entry = createMockEntry("m1", "established");
251
+ const score = createMockScore("m1", 5.1, 0.75, 8, 3); // Fresh votes
252
+ const result = evaluatePromotion(entry, score);
253
+
254
+ expect(result.new_status).toBe("mandate");
255
+ expect(result.promoted).toBe(true);
256
+ });
257
+ });
258
+
259
+ // ============================================================================
260
+ // Utility Functions Tests
261
+ // ============================================================================
262
+
263
+ describe("formatPromotionResult", () => {
264
+ it("formats promoted result with arrow", () => {
265
+ const entry = createMockEntry("m1", "candidate");
266
+ const score = createMockScore("m1", 2, 1.0);
267
+ const result = evaluatePromotion(entry, score);
268
+ const formatted = formatPromotionResult(result);
269
+
270
+ expect(formatted).toContain("[m1]");
271
+ expect(formatted).toContain("candidate → established");
272
+ });
273
+
274
+ it("formats no-change result without arrow", () => {
275
+ const entry = createMockEntry("m1", "candidate");
276
+ const score = createMockScore("m1", 1, 0.8);
277
+ const result = evaluatePromotion(entry, score);
278
+ const formatted = formatPromotionResult(result);
279
+
280
+ expect(formatted).toContain("[m1]");
281
+ expect(formatted).toContain("candidate");
282
+ expect(formatted).not.toContain("→");
283
+ });
284
+ });
285
+
286
+ describe("evaluateBatchPromotions", () => {
287
+ it("evaluates multiple entries", () => {
288
+ const entries = new Map([
289
+ ["m1", createMockEntry("m1", "candidate")],
290
+ ["m2", createMockEntry("m2", "established")],
291
+ ["m3", createMockEntry("m3", "mandate")],
292
+ ]);
293
+
294
+ const scores = new Map([
295
+ ["m1", createMockScore("m1", 2, 1.0)], // Will promote
296
+ ["m2", createMockScore("m2", 5, 0.7)], // Will promote
297
+ ["m3", createMockScore("m3", 10, 0.9)], // Stays mandate
298
+ ]);
299
+
300
+ const results = evaluateBatchPromotions(entries, scores);
301
+
302
+ expect(results).toHaveLength(3);
303
+ expect(results[0].new_status).toBe("established");
304
+ expect(results[1].new_status).toBe("mandate");
305
+ expect(results[2].new_status).toBe("mandate");
306
+ });
307
+
308
+ it("skips entries without scores", () => {
309
+ const entries = new Map([
310
+ ["m1", createMockEntry("m1", "candidate")],
311
+ ["m2", createMockEntry("m2", "established")],
312
+ ]);
313
+
314
+ const scores = new Map([
315
+ ["m1", createMockScore("m1", 2, 1.0)],
316
+ // m2 has no score
317
+ ]);
318
+
319
+ const results = evaluateBatchPromotions(entries, scores);
320
+
321
+ expect(results).toHaveLength(1);
322
+ expect(results[0].mandate_id).toBe("m1");
323
+ });
324
+ });
325
+
326
+ describe("getStatusChanges", () => {
327
+ it("filters to only promoted entries", () => {
328
+ const results = [
329
+ {
330
+ mandate_id: "m1",
331
+ previous_status: "candidate" as const,
332
+ new_status: "established" as const,
333
+ score: createMockScore("m1", 2, 1.0),
334
+ promoted: true,
335
+ reason: "Promoted",
336
+ },
337
+ {
338
+ mandate_id: "m2",
339
+ previous_status: "candidate" as const,
340
+ new_status: "candidate" as const,
341
+ score: createMockScore("m2", 1, 0.8),
342
+ promoted: false,
343
+ reason: "No change",
344
+ },
345
+ {
346
+ mandate_id: "m3",
347
+ previous_status: "established" as const,
348
+ new_status: "mandate" as const,
349
+ score: createMockScore("m3", 5, 0.7),
350
+ promoted: true,
351
+ reason: "Promoted",
352
+ },
353
+ ];
354
+
355
+ const changes = getStatusChanges(results);
356
+
357
+ expect(changes).toHaveLength(2);
358
+ expect(changes[0].mandate_id).toBe("m1");
359
+ expect(changes[1].mandate_id).toBe("m3");
360
+ });
361
+ });
362
+
363
+ describe("groupByTransition", () => {
364
+ it("groups results by transition type", () => {
365
+ const results = [
366
+ {
367
+ mandate_id: "m1",
368
+ previous_status: "candidate" as const,
369
+ new_status: "established" as const,
370
+ score: createMockScore("m1", 2, 1.0),
371
+ promoted: true,
372
+ reason: "Promoted",
373
+ },
374
+ {
375
+ mandate_id: "m2",
376
+ previous_status: "candidate" as const,
377
+ new_status: "established" as const,
378
+ score: createMockScore("m2", 3, 1.0),
379
+ promoted: true,
380
+ reason: "Promoted",
381
+ },
382
+ {
383
+ mandate_id: "m3",
384
+ previous_status: "established" as const,
385
+ new_status: "mandate" as const,
386
+ score: createMockScore("m3", 5, 0.7),
387
+ promoted: true,
388
+ reason: "Promoted",
389
+ },
390
+ {
391
+ mandate_id: "m4",
392
+ previous_status: "candidate" as const,
393
+ new_status: "candidate" as const,
394
+ score: createMockScore("m4", 1, 0.8),
395
+ promoted: false,
396
+ reason: "No change",
397
+ },
398
+ ];
399
+
400
+ const grouped = groupByTransition(results);
401
+
402
+ expect(grouped.size).toBe(3);
403
+ expect(grouped.get("candidate→established")).toHaveLength(2);
404
+ expect(grouped.get("established→mandate")).toHaveLength(1);
405
+ expect(grouped.get("candidate")).toHaveLength(1);
406
+ });
407
+
408
+ it("uses status name for no-change transitions", () => {
409
+ const results = [
410
+ {
411
+ mandate_id: "m1",
412
+ previous_status: "mandate" as const,
413
+ new_status: "mandate" as const,
414
+ score: createMockScore("m1", 10, 0.9),
415
+ promoted: false,
416
+ reason: "Stays",
417
+ },
418
+ {
419
+ mandate_id: "m2",
420
+ previous_status: "rejected" as const,
421
+ new_status: "rejected" as const,
422
+ score: createMockScore("m2", -5, 0.1),
423
+ promoted: false,
424
+ reason: "Stays",
425
+ },
426
+ ];
427
+
428
+ const grouped = groupByTransition(results);
429
+
430
+ expect(grouped.size).toBe(2);
431
+ expect(grouped.get("mandate")).toHaveLength(1);
432
+ expect(grouped.get("rejected")).toHaveLength(1);
433
+ });
434
+ });
435
+
436
+ // ============================================================================
437
+ // Edge Cases
438
+ // ============================================================================
439
+
440
+ describe("edge cases", () => {
441
+ it("handles exact threshold values", () => {
442
+ const entry1 = createMockEntry("m1", "candidate");
443
+ const score1 = createMockScore("m1", 2.0, 1.0); // Exact threshold
444
+ const result1 = evaluatePromotion(entry1, score1);
445
+ expect(result1.new_status).toBe("established");
446
+
447
+ const entry2 = createMockEntry("m2", "established");
448
+ const score2 = createMockScore("m2", 5.0, 0.7); // Exact threshold
449
+ const result2 = evaluatePromotion(entry2, score2);
450
+ expect(result2.new_status).toBe("mandate");
451
+ });
452
+
453
+ it("handles zero votes", () => {
454
+ const entry = createMockEntry("m1", "candidate");
455
+ const score = createMockScore("m1", 0, 0);
456
+ const result = evaluatePromotion(entry, score);
457
+ expect(result.new_status).toBe("candidate");
458
+ });
459
+
460
+ it("handles negative vote ratio edge case", () => {
461
+ const entry = createMockEntry("m1", "established");
462
+ const score = createMockScore("m1", 5, 0.2, 1, 4); // Low ratio
463
+ const result = evaluatePromotion(entry, score);
464
+ expect(result.new_status).toBe("established"); // ratio < 0.7
465
+ });
466
+
467
+ it("rejects at exact rejection threshold", () => {
468
+ const entry = createMockEntry("m1", "candidate");
469
+ const score = createMockScore("m1", -3, 0.1);
470
+ const result = evaluatePromotion(entry, score);
471
+ expect(result.new_status).toBe("rejected");
472
+ });
473
+ });