@holoscript/plugin-government-civic 2.0.1

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,365 @@
1
+ /**
2
+ * Government & civic analytics solvers — government-civic-plugin
3
+ *
4
+ * Implements:
5
+ * - Permit scoring (weighted compliance criteria)
6
+ * - MCDA (Multi-Criteria Decision Analysis) — weighted sum + TOPSIS
7
+ * - Quorum calculator (simple majority, supermajority, absolute)
8
+ * - Legislative voting cohesion (Rice index)
9
+ * - Polsby-Popper compactness score (electoral district)
10
+ * - Budget variance analysis
11
+ * - Population-weighted equity index (Gini coefficient on service access)
12
+ *
13
+ * References:
14
+ * - Polsby D, Popper R (1991) 9 Yale L. & Pol'y Rev. 301 — compactness
15
+ * - Rice S (1928) Am. J. Sociology 33:688-708 — cohesion index
16
+ * - Hwang C, Yoon K (1981) Multiple Attribute Decision Making — TOPSIS
17
+ * - US Municipal Budget Variance Standards (GFOA)
18
+ */
19
+
20
+ import { buildDomainSimulationReceipt, type DomainSimulationReceipt } from '@holoscript/core';
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ export interface PermitCriteria {
25
+ name: string;
26
+ weight: number; // sum of all weights should be 1.0
27
+ score: number; // 0–100
28
+ }
29
+
30
+ export interface PermitScoringResult {
31
+ /** Weighted composite score 0–100 */
32
+ compositeScore: number;
33
+ /** Pass/fail threshold (default 70) */
34
+ approved: boolean;
35
+ /** Per-criterion breakdown */
36
+ breakdown: Array<{ name: string; score: number; weight: number; contribution: number }>;
37
+ /** Threshold applied */
38
+ threshold: number;
39
+ }
40
+
41
+ export interface MCDACandidate {
42
+ id: string;
43
+ /** Criterion scores (must align with MCDACriterion array) */
44
+ scores: number[];
45
+ }
46
+
47
+ export interface MCDACriterion {
48
+ name: string;
49
+ weight: number; // 0–1
50
+ /** true = higher is better (benefit), false = lower is better (cost) */
51
+ isBenefit: boolean;
52
+ }
53
+
54
+ export interface MCDAResult {
55
+ /** Ranked candidates with scores */
56
+ ranking: Array<{
57
+ id: string;
58
+ weightedScore: number;
59
+ topsisScore: number;
60
+ rank: number;
61
+ }>;
62
+ /** Best candidate id by TOPSIS */
63
+ winner: string;
64
+ }
65
+
66
+ export type QuorumType = 'simple' | 'supermajority' | 'absolute';
67
+
68
+ export interface QuorumResult {
69
+ totalMembers: number;
70
+ presentMembers: number;
71
+ yesVotes: number;
72
+ noVotes: number;
73
+ abstentions: number;
74
+ quorumMet: boolean;
75
+ passThreshold: number;
76
+ motionPassed: boolean;
77
+ marginVotes: number;
78
+ }
79
+
80
+ export interface VotingRecord {
81
+ memberId: string;
82
+ partyId: string;
83
+ vote: 'yes' | 'no' | 'abstain';
84
+ }
85
+
86
+ export interface CohesionResult {
87
+ /** Overall chamber Rice index (0=perfect split, 1=unanimous) */
88
+ chamberCohesion: number;
89
+ /** Per-party Rice cohesion */
90
+ partyCohesion: Array<{ partyId: string; cohesion: number; yesCount: number; noCount: number; size: number }>;
91
+ /** Winning coalition */
92
+ majority: 'yes' | 'no' | 'tie';
93
+ }
94
+
95
+ export interface PolsbyPopperResult {
96
+ /** District area (sq km or any consistent unit) */
97
+ areaSqKm: number;
98
+ /** District perimeter (same unit as area sqrt) */
99
+ perimeterKm: number;
100
+ /** Polsby-Popper score = 4π × area / perimeter² ∈ (0,1] */
101
+ compactnessScore: number;
102
+ /** Qualitative classification */
103
+ classification: 'compact' | 'moderate' | 'gerrymandered';
104
+ }
105
+
106
+ export interface BudgetLine {
107
+ category: string;
108
+ budgeted: number;
109
+ actual: number;
110
+ }
111
+
112
+ export interface BudgetVarianceResult {
113
+ lines: Array<{
114
+ category: string;
115
+ budgeted: number;
116
+ actual: number;
117
+ variance: number;
118
+ variancePct: number;
119
+ status: 'favorable' | 'unfavorable' | 'on-target';
120
+ }>;
121
+ totalBudgeted: number;
122
+ totalActual: number;
123
+ totalVariancePct: number;
124
+ overallStatus: 'favorable' | 'unfavorable' | 'on-target';
125
+ flaggedLines: string[]; // categories with variance > 10%
126
+ }
127
+
128
+ export interface GovtReceiptOptions {
129
+ runId?: string;
130
+ }
131
+
132
+ // ─── Permit Scoring ───────────────────────────────────────────────────────────
133
+
134
+ export function permitScoring(
135
+ criteria: PermitCriteria[],
136
+ threshold = 70,
137
+ ): PermitScoringResult {
138
+ if (criteria.length === 0) throw new Error('No permit criteria provided');
139
+ const totalWeight = criteria.reduce((s, c) => s + c.weight, 0);
140
+ if (Math.abs(totalWeight - 1.0) > 0.01) throw new Error(`Weights must sum to 1.0 (got ${totalWeight.toFixed(3)})`);
141
+
142
+ const breakdown = criteria.map(c => ({
143
+ name: c.name,
144
+ score: c.score,
145
+ weight: c.weight,
146
+ contribution: c.score * c.weight,
147
+ }));
148
+
149
+ const compositeScore = breakdown.reduce((s, b) => s + b.contribution, 0);
150
+
151
+ return { compositeScore, approved: compositeScore >= threshold, breakdown, threshold };
152
+ }
153
+
154
+ // ─── MCDA — Weighted Sum + TOPSIS ────────────────────────────────────────────
155
+
156
+ /** Normalize a column to [0,1] range */
157
+ function normalizeColumn(values: number[]): number[] {
158
+ const min = Math.min(...values), max = Math.max(...values);
159
+ if (max === min) return values.map(() => 0.5);
160
+ return values.map(v => (v - min) / (max - min));
161
+ }
162
+
163
+ export function mcdaAnalysis(
164
+ candidates: MCDACandidate[],
165
+ criteria: MCDACriterion[],
166
+ ): MCDAResult {
167
+ if (candidates.length === 0) throw new Error('No MCDA candidates');
168
+ if (criteria.length === 0) throw new Error('No MCDA criteria');
169
+ if (candidates.some(c => c.scores.length !== criteria.length)) throw new Error('scores.length must match criteria.length');
170
+
171
+ const n = candidates.length, m = criteria.length;
172
+
173
+ // Weighted sum score
174
+ const weightedScores = candidates.map(c =>
175
+ c.scores.reduce((acc, s, j) => acc + s * criteria[j].weight * (criteria[j].isBenefit ? 1 : -1), 0),
176
+ );
177
+
178
+ // TOPSIS: normalize → weight → ideal/anti-ideal → distance
179
+ const normalizedMatrix: number[][] = Array.from({ length: n }, () => Array(m).fill(0));
180
+ for (let j = 0; j < m; j++) {
181
+ const col = candidates.map(c => c.scores[j]);
182
+ const norm = normalizeColumn(col);
183
+ for (let i = 0; i < n; i++) {
184
+ normalizedMatrix[i][j] = norm[i] * criteria[j].weight;
185
+ }
186
+ }
187
+
188
+ // Ideal best/worst per criterion
189
+ const ideal = criteria.map((cr, j) => cr.isBenefit ? Math.max(...normalizedMatrix.map(r => r[j])) : Math.min(...normalizedMatrix.map(r => r[j])));
190
+ const antiIdeal = criteria.map((cr, j) => cr.isBenefit ? Math.min(...normalizedMatrix.map(r => r[j])) : Math.max(...normalizedMatrix.map(r => r[j])));
191
+
192
+ const topsisScores = normalizedMatrix.map(row => {
193
+ const dPos = Math.sqrt(row.reduce((acc, v, j) => acc + (v - ideal[j]) ** 2, 0));
194
+ const dNeg = Math.sqrt(row.reduce((acc, v, j) => acc + (v - antiIdeal[j]) ** 2, 0));
195
+ return dNeg / (dPos + dNeg + 1e-15);
196
+ });
197
+
198
+ const ranked = candidates
199
+ .map((c, i) => ({ id: c.id, weightedScore: weightedScores[i], topsisScore: topsisScores[i] }))
200
+ .sort((a, b) => b.topsisScore - a.topsisScore)
201
+ .map((r, idx) => ({ ...r, rank: idx + 1 }));
202
+
203
+ return { ranking: ranked, winner: ranked[0].id };
204
+ }
205
+
206
+ // ─── Quorum Calculator ────────────────────────────────────────────────────────
207
+
208
+ export function quorumCalculator(
209
+ totalMembers: number,
210
+ presentMembers: number,
211
+ yesVotes: number,
212
+ noVotes: number,
213
+ abstentions: number,
214
+ quorumType: QuorumType = 'simple',
215
+ ): QuorumResult {
216
+ if (totalMembers < 1) throw new Error('totalMembers must be ≥ 1');
217
+ if (presentMembers > totalMembers) throw new Error('presentMembers cannot exceed totalMembers');
218
+ if (yesVotes + noVotes + abstentions > presentMembers) throw new Error('Votes exceed present members');
219
+
220
+ const quorumRequired = Math.floor(totalMembers / 2) + 1; // strict majority
221
+ const quorumMet = presentMembers >= quorumRequired;
222
+
223
+ const passThreshold =
224
+ quorumType === 'supermajority' ? (2 / 3) :
225
+ quorumType === 'absolute' ? (totalMembers / 2 + 1) / totalMembers :
226
+ 0.5; // simple majority of votes cast
227
+
228
+ const votingBase = quorumType === 'absolute' ? totalMembers : yesVotes + noVotes;
229
+ const motionPassed = quorumMet && votingBase > 0 && (yesVotes / votingBase) > passThreshold;
230
+ const marginVotes = motionPassed
231
+ ? yesVotes - Math.ceil((yesVotes + noVotes) * passThreshold)
232
+ : Math.ceil((yesVotes + noVotes) * passThreshold) - yesVotes;
233
+
234
+ return { totalMembers, presentMembers, yesVotes, noVotes, abstentions, quorumMet, passThreshold, motionPassed, marginVotes };
235
+ }
236
+
237
+ // ─── Voting Cohesion (Rice Index) ────────────────────────────────────────────
238
+
239
+ /**
240
+ * Rice cohesion index for a party = |yes% - no%| (range 0–1).
241
+ * Chamber overall = average of all non-abstain votes as one party.
242
+ */
243
+ export function votingCohesion(votes: VotingRecord[]): CohesionResult {
244
+ if (votes.length === 0) throw new Error('No voting records');
245
+
246
+ const partyMap = new Map<string, { yes: number; no: number; abstain: number }>();
247
+ for (const v of votes) {
248
+ if (!partyMap.has(v.partyId)) partyMap.set(v.partyId, { yes: 0, no: 0, abstain: 0 });
249
+ partyMap.get(v.partyId)![v.vote]++;
250
+ }
251
+
252
+ const partyCohesion = [...partyMap.entries()].map(([partyId, counts]) => {
253
+ const total = counts.yes + counts.no;
254
+ const cohesion = total > 0 ? Math.abs((counts.yes - counts.no) / total) : 0;
255
+ return { partyId, cohesion, yesCount: counts.yes, noCount: counts.no, size: counts.yes + counts.no + counts.abstain };
256
+ });
257
+
258
+ const totalYes = votes.filter(v => v.vote === 'yes').length;
259
+ const totalNo = votes.filter(v => v.vote === 'no').length;
260
+ const totalVoting = totalYes + totalNo;
261
+ const chamberCohesion = totalVoting > 0 ? Math.abs((totalYes - totalNo) / totalVoting) : 0;
262
+
263
+ const majority: CohesionResult['majority'] =
264
+ totalYes > totalNo ? 'yes' :
265
+ totalNo > totalYes ? 'no' : 'tie';
266
+
267
+ return { chamberCohesion, partyCohesion, majority };
268
+ }
269
+
270
+ // ─── Polsby-Popper Compactness ────────────────────────────────────────────────
271
+
272
+ /**
273
+ * Polsby-Popper score = 4π × A / P²
274
+ * Circle → 1.0; elongated/complex shapes → toward 0.
275
+ */
276
+ export function polsbyPopper(areaSqKm: number, perimeterKm: number): PolsbyPopperResult {
277
+ if (areaSqKm <= 0) throw new Error('Area must be positive');
278
+ if (perimeterKm <= 0) throw new Error('Perimeter must be positive');
279
+
280
+ const compactnessScore = (4 * Math.PI * areaSqKm) / (perimeterKm ** 2);
281
+
282
+ const classification: PolsbyPopperResult['classification'] =
283
+ compactnessScore >= 0.50 ? 'compact' :
284
+ compactnessScore >= 0.20 ? 'moderate' : 'gerrymandered';
285
+
286
+ return { areaSqKm, perimeterKm, compactnessScore, classification };
287
+ }
288
+
289
+ // ─── Budget Variance ──────────────────────────────────────────────────────────
290
+
291
+ export function budgetVariance(
292
+ lines: BudgetLine[],
293
+ flagThreshold = 0.10,
294
+ ): BudgetVarianceResult {
295
+ if (lines.length === 0) throw new Error('No budget lines');
296
+
297
+ const analyzed = lines.map(l => {
298
+ const variance = l.actual - l.budgeted;
299
+ const variancePct = l.budgeted !== 0 ? variance / l.budgeted : 0;
300
+ const status: 'favorable' | 'unfavorable' | 'on-target' =
301
+ Math.abs(variancePct) < 0.02 ? 'on-target' :
302
+ variance < 0 ? 'favorable' : 'unfavorable'; // under-budget = favorable
303
+ return { category: l.category, budgeted: l.budgeted, actual: l.actual, variance, variancePct, status };
304
+ });
305
+
306
+ const totalBudgeted = analyzed.reduce((s, l) => s + l.budgeted, 0);
307
+ const totalActual = analyzed.reduce((s, l) => s + l.actual, 0);
308
+ const totalVariancePct = totalBudgeted !== 0 ? (totalActual - totalBudgeted) / totalBudgeted : 0;
309
+ const overallStatus: BudgetVarianceResult['overallStatus'] =
310
+ Math.abs(totalVariancePct) < 0.02 ? 'on-target' :
311
+ totalVariancePct < 0 ? 'favorable' : 'unfavorable';
312
+
313
+ const flaggedLines = analyzed.filter(l => Math.abs(l.variancePct) > flagThreshold).map(l => l.category);
314
+
315
+ return { lines: analyzed, totalBudgeted, totalActual, totalVariancePct, overallStatus, flaggedLines };
316
+ }
317
+
318
+ // ─── Receipt ──────────────────────────────────────────────────────────────────
319
+
320
+ export interface GovtAnalysisResult {
321
+ permit?: PermitScoringResult;
322
+ mcda?: MCDAResult;
323
+ quorum?: QuorumResult;
324
+ cohesion?: CohesionResult;
325
+ compactness?: PolsbyPopperResult;
326
+ budgetVar?: BudgetVarianceResult;
327
+ converged: true;
328
+ }
329
+
330
+ export function buildGovtReceipt(
331
+ result: GovtAnalysisResult,
332
+ options?: GovtReceiptOptions,
333
+ ): DomainSimulationReceipt {
334
+ const violations: Array<{ criterion: string; message: string }> = [];
335
+
336
+ if (result.permit && !result.permit.approved) {
337
+ violations.push({ criterion: 'permit_denied', message: `Permit score ${result.permit.compositeScore.toFixed(1)} < ${result.permit.threshold} threshold — application denied` });
338
+ }
339
+ if (result.quorum && !result.quorum.quorumMet) {
340
+ violations.push({ criterion: 'no_quorum', message: `Only ${result.quorum.presentMembers}/${result.quorum.totalMembers} members present — quorum not met` });
341
+ }
342
+ if (result.compactness && result.compactness.classification === 'gerrymandered') {
343
+ violations.push({ criterion: 'gerrymandering', message: `District compactness ${result.compactness.compactnessScore.toFixed(3)} < 0.20 — potential gerrymandering` });
344
+ }
345
+ if (result.budgetVar && result.budgetVar.overallStatus === 'unfavorable' && Math.abs(result.budgetVar.totalVariancePct) > 0.05) {
346
+ violations.push({ criterion: 'budget_overrun', message: `Budget overrun ${(result.budgetVar.totalVariancePct * 100).toFixed(1)}% exceeds 5% threshold` });
347
+ }
348
+
349
+ return buildDomainSimulationReceipt({
350
+ plugin: 'government-civic',
351
+ pluginVersion: '1.0.0',
352
+ runId: options?.runId ?? `gov-${Date.now().toString(36)}`,
353
+ solverConfig: { solverType: 'civic-analytics', scale: 'municipality' },
354
+ resultSummary: {
355
+ permitScore: result.permit?.compositeScore ?? null,
356
+ mcdaWinner: result.mcda?.winner ?? null,
357
+ quorumMet: result.quorum?.quorumMet ?? null,
358
+ chamberCohesion: result.cohesion?.chamberCohesion ?? null,
359
+ districtCompactness: result.compactness?.compactnessScore ?? null,
360
+ budgetVariancePct: result.budgetVar?.totalVariancePct ?? null,
361
+ },
362
+ cael: { version: 'cael.v1', event: 'government_civic.civic_analysis', solverType: 'government-civic.permit-scoring' },
363
+ acceptance: { accepted: violations.length === 0, violations },
364
+ });
365
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export * from './civicsolver';
2
+ export { createPermitHandler, type PermitConfig, type PermitState, type PermitType, type PermitStatus } from './traits/PermitTrait';
3
+ export { createPublicMeetingHandler, type PublicMeetingConfig, type PublicMeetingState, type MeetingType, type MeetingStatus, type AgendaItem } from './traits/PublicMeetingTrait';
4
+ export { createServiceRequestHandler, type ServiceRequestConfig, type ServiceRequestState, type ServiceCategory, type RequestStatus, type PriorityLevel } from './traits/ServiceRequestTrait';
5
+ export { createVotingRecordHandler, type VotingRecordConfig, type VotingRecordState, type VoteType, type VoteOutcome, type VoteCast } from './traits/VotingRecordTrait';
6
+ export { createCivicComplianceHandler, type CivicComplianceConfig, type CivicComplianceState, type ComplianceFramework, type ComplianceStatus, type ComplianceCheck, type FoiaRequest } from './traits/CivicComplianceTrait';
7
+ export * from './traits/types';
8
+
9
+ import { createPermitHandler } from './traits/PermitTrait';
10
+ import { createPublicMeetingHandler } from './traits/PublicMeetingTrait';
11
+ import { createServiceRequestHandler } from './traits/ServiceRequestTrait';
12
+ import { createVotingRecordHandler } from './traits/VotingRecordTrait';
13
+ import { createCivicComplianceHandler } from './traits/CivicComplianceTrait';
14
+
15
+ export const pluginMeta = {
16
+ name: '@holoscript/plugin-government-civic',
17
+ version: '1.0.0',
18
+ traits: ['permit', 'public_meeting', 'service_request', 'voting_record', 'civic_compliance', 'civic_decision'],
19
+ };
20
+
21
+ // Runtime integration — behavioral trait handler + registrar that wire the
22
+ // deterministic TOPSIS MCDA solver into HoloScriptRuntime's dispatch. Closes
23
+ // the built-but-dead-wired gap for `civic_decision`, mirroring energy-grid's
24
+ // `power_flow` reference integration.
25
+ export {
26
+ GOVERNMENT_CIVIC_PLUGIN_ID,
27
+ civicDecisionHandler,
28
+ registerGovernmentCivicTraitHandlers,
29
+ type CivicDecisionTraitConfig,
30
+ type CivicDecisionSolvedEvent,
31
+ type RuntimeTraitHandler,
32
+ type TraitRegistrar,
33
+ } from './runtime';
34
+
35
+ export const traitHandlers = [
36
+ createPermitHandler(),
37
+ createPublicMeetingHandler(),
38
+ createServiceRequestHandler(),
39
+ createVotingRecordHandler(),
40
+ createCivicComplianceHandler(),
41
+ ];
package/src/runtime.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Runtime integration for @holoscript/plugin-government-civic.
3
+ *
4
+ * Bridges the previously dead-wired `civic_decision` trait into a behavioral
5
+ * TraitHandler that the HoloScript runtime actually dispatches
6
+ * (HoloScriptRuntime.registerTrait -> applyDirectives / updateTraits).
7
+ *
8
+ * Before this module the plugin declared trait NAMES only (pluginMeta.traits)
9
+ * and exported the civic solvers (mcdaAnalysis, quorumCalculator, …), but
10
+ * nothing invoked a solver THROUGH the runtime — the whole domain-plugin tier
11
+ * was built-but-dead-wired. This mirrors energy-grid-plugin's reference
12
+ * integration (power_flow): it wires the deterministic TOPSIS multi-criteria
13
+ * decision solver (`mcdaAnalysis`) behind the `civic_decision` trait so the
14
+ * runtime's directive dispatch can run it. The remaining civic traits follow
15
+ * the same registrar shape.
16
+ */
17
+ import { registerPluginTraits } from '@holoscript/core/runtime';
18
+ import {
19
+ mcdaAnalysis,
20
+ type MCDACandidate,
21
+ type MCDACriterion,
22
+ type MCDAResult,
23
+ } from './civicsolver';
24
+
25
+ /** Stable id for this plugin's trait ownership tagging. */
26
+ export const GOVERNMENT_CIVIC_PLUGIN_ID = 'government-civic' as const;
27
+
28
+ /** Config carried by an orb's `@civic_decision` trait directive. */
29
+ export interface CivicDecisionTraitConfig {
30
+ /** Decision alternatives to rank. Required; absence emits `civic_decision_error`. */
31
+ candidates?: MCDACandidate[];
32
+ /** Weighted criteria (benefit/cost) the candidates are scored against. Required. */
33
+ criteria?: MCDACriterion[];
34
+ }
35
+
36
+ /** Summary payload emitted on `civic_decision_solved`. */
37
+ export interface CivicDecisionSolvedEvent {
38
+ nodeId: string;
39
+ /** Winning candidate id by TOPSIS closeness coefficient. */
40
+ winner: string;
41
+ /** Full ranked decision matrix. */
42
+ ranking: MCDAResult['ranking'];
43
+ /** Candidate count evaluated. */
44
+ candidateCount: number;
45
+ /** Criteria count applied. */
46
+ criterionCount: number;
47
+ }
48
+
49
+ /**
50
+ * Structural view of the runtime trait-handler contract. Matches
51
+ * `@holoscript/core` TraitTypes.TraitHandler at the call sites the runtime
52
+ * actually uses (onAttach / onUpdate receive the node, the directive config,
53
+ * and a context exposing `emit`). Declared locally so the plugin stays
54
+ * decoupled from core's full trait surface.
55
+ */
56
+ export interface TraitDispatchContext {
57
+ emit: (event: string, payload?: unknown) => void;
58
+ setState?: (updates: Record<string, unknown>) => void;
59
+ }
60
+
61
+ export interface RuntimeTraitHandler {
62
+ name: string;
63
+ onAttach?: (node: unknown, config: CivicDecisionTraitConfig, context: TraitDispatchContext) => void;
64
+ onUpdate?: (
65
+ node: unknown,
66
+ config: CivicDecisionTraitConfig,
67
+ context: TraitDispatchContext,
68
+ delta: number,
69
+ ) => void;
70
+ }
71
+
72
+ interface CivicDecisionNode {
73
+ id?: string;
74
+ name?: string;
75
+ properties?: Record<string, unknown>;
76
+ __civicDecisionResult?: MCDAResult;
77
+ }
78
+
79
+ /** Run the MCDA solver on the directive config, write the result onto the node, and emit. */
80
+ function solveOntoNode(
81
+ node: unknown,
82
+ config: CivicDecisionTraitConfig | undefined,
83
+ context: TraitDispatchContext,
84
+ ): void {
85
+ const carrier = node as CivicDecisionNode;
86
+ const nodeId = carrier.id ?? carrier.name ?? 'unknown';
87
+ const candidates = config?.candidates;
88
+ const criteria = config?.criteria;
89
+
90
+ if (!candidates || !criteria) {
91
+ context.emit('civic_decision_error', {
92
+ nodeId,
93
+ error:
94
+ 'civic_decision trait requires config.candidates (MCDACandidate[]) and config.criteria (MCDACriterion[])',
95
+ });
96
+ return;
97
+ }
98
+
99
+ try {
100
+ const result = mcdaAnalysis(candidates, criteria);
101
+ carrier.__civicDecisionResult = result;
102
+ carrier.properties = {
103
+ ...(carrier.properties ?? {}),
104
+ civicDecisionWinner: result.winner,
105
+ civicDecisionCandidateCount: result.ranking.length,
106
+ };
107
+ const summary: CivicDecisionSolvedEvent = {
108
+ nodeId,
109
+ winner: result.winner,
110
+ ranking: result.ranking,
111
+ candidateCount: candidates.length,
112
+ criterionCount: criteria.length,
113
+ };
114
+ context.setState?.({ [`civic_decision:${nodeId}`]: summary });
115
+ context.emit('civic_decision_solved', summary);
116
+ } catch (error) {
117
+ context.emit('civic_decision_error', {
118
+ nodeId,
119
+ error: error instanceof Error ? error.message : String(error),
120
+ });
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Behavioral handler for the government-civic `civic_decision` trait. Runs the
126
+ * deterministic TOPSIS multi-criteria decision solver whenever an orb carrying
127
+ * the trait is attached (and on each per-frame update), writing the result onto
128
+ * the node and emitting `civic_decision_solved` / `civic_decision_error`.
129
+ */
130
+ export const civicDecisionHandler: RuntimeTraitHandler = {
131
+ name: 'civic_decision',
132
+ onAttach: (node, config, context) => solveOntoNode(node, config, context),
133
+ onUpdate: (node, config, context) => solveOntoNode(node, config, context),
134
+ };
135
+
136
+ /** A runtime that can register behavioral trait handlers. */
137
+ export interface TraitRegistrar {
138
+ registerTrait(name: string, handler: unknown): void;
139
+ }
140
+
141
+ /**
142
+ * Register government-civic behavioral trait handlers into a runtime that
143
+ * exposes `registerTrait(name, handler)` — e.g. `@holoscript/core`
144
+ * HoloScriptRuntime. This is the consumption path the dead-wired tier was
145
+ * missing: after this call the runtime's directive dispatch (applyDirectives /
146
+ * updateTraits) will invoke the civic MCDA solver for `@civic_decision` orbs.
147
+ */
148
+ export function registerGovernmentCivicTraitHandlers(registrar: TraitRegistrar): void {
149
+ registerPluginTraits(registrar, GOVERNMENT_CIVIC_PLUGIN_ID, [civicDecisionHandler]);
150
+ }