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/.beads/analysis/skill-architecture-meta-skills.md +1562 -0
- package/.beads/issues.jsonl +79 -0
- package/README.md +20 -18
- package/VERIFICATION_QUALITY_PATTERNS.md +565 -0
- package/bin/swarm.ts +5 -5
- package/dist/index.js +1318 -28
- package/dist/plugin.js +1218 -14
- package/docs/analysis/subagent-coordination-patterns.md +900 -0
- package/docs/analysis-socratic-planner-pattern.md +504 -0
- package/examples/commands/swarm.md +112 -7
- package/global-skills/swarm-coordination/SKILL.md +118 -20
- package/global-skills/swarm-coordination/references/coordinator-patterns.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +78 -0
- package/src/learning.integration.test.ts +310 -0
- package/src/learning.ts +198 -0
- package/src/mandate-promotion.test.ts +473 -0
- package/src/mandate-promotion.ts +239 -0
- package/src/mandate-storage.test.ts +578 -0
- package/src/mandate-storage.ts +786 -0
- package/src/mandates.ts +540 -0
- package/src/schemas/index.ts +27 -0
- package/src/schemas/mandate.ts +232 -0
- package/src/skills.test.ts +194 -0
- package/src/skills.ts +184 -15
- package/src/swarm.integration.test.ts +4 -4
- package/src/swarm.ts +496 -19
- package/workflow-integration-analysis.md +876 -0
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
|
+
});
|