@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +14 -0
- package/src/__tests__/civicsolver.test.ts +360 -0
- package/src/__tests__/government-civic.test.ts +302 -0
- package/src/__tests__/runtime-integration.test.ts +128 -0
- package/src/civicsolver.ts +365 -0
- package/src/index.ts +41 -0
- package/src/runtime.ts +150 -0
- package/src/traits/CivicComplianceTrait.ts +165 -0
- package/src/traits/PermitTrait.ts +107 -0
- package/src/traits/PublicMeetingTrait.ts +123 -0
- package/src/traits/ServiceRequestTrait.ts +124 -0
- package/src/traits/VotingRecordTrait.ts +128 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +17 -0
|
@@ -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
|
+
}
|