pi2web 0.2.0 → 0.2.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,230 @@
1
+ import { COLLAB_ERRORS, can } from './types.js';
2
+ import { flowError } from './review-flow.js';
3
+ const mean = (values) => values.reduce((total, value) => total + value, 0) / values.length;
4
+ const median = (values) => { const sorted = [...values].sort((a, b) => a - b), middle = sorted.length >> 1; return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; };
5
+ const stdev = (values) => { if (values.length < 2)
6
+ return 0; const average = mean(values); return Math.sqrt(values.reduce((total, value) => total + (value - average) ** 2, 0) / (values.length - 1)); };
7
+ const round2 = (value) => Math.round(value * 100) / 100;
8
+ /** Only reviewers score. The implementer is recused so the party under review cannot grade itself. */
9
+ export const scoringPanel = (snapshot) => snapshot.participants.filter(participant => participant.state !== 'left' && participant.role === 'reviewer');
10
+ export function assertScoringCapability(participant, capability) {
11
+ if (participant.state === 'budget_exhausted')
12
+ throw flowError(COLLAB_ERRORS.budgetExhausted, 'Token budget is exhausted for this participant', 429);
13
+ if (participant.state === 'left')
14
+ throw flowError(COLLAB_ERRORS.forbidden, 'This participant has left the session', 403);
15
+ if (!can(participant.role, capability))
16
+ throw flowError(COLLAB_ERRORS.forbidden, `Role ${participant.role} may not ${capability} in a scoring session`, 403);
17
+ }
18
+ export function assertScoringPhase(snapshot, phases, action) {
19
+ if (snapshot.status !== 'active')
20
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Session is ${snapshot.status}; ${action} is no longer accepted`);
21
+ if (!phases.includes(snapshot.phase))
22
+ throw flowError(COLLAB_ERRORS.wrongPhase, `${action} is only accepted in phase ${phases.join(' or ')}, but the session is in ${snapshot.phase}`);
23
+ }
24
+ export const approvedCriteria = (snapshot) => snapshot.criteria.filter(criterion => criterion.state === 'approved');
25
+ export const candidateCriteria = (snapshot) => snapshot.criteria.filter(criterion => criterion.state === 'candidate');
26
+ export const contestedCriteria = (snapshot) => analyse(snapshot).filter(entry => entry.contested);
27
+ /** Everyone on the panel must submit before a phase closes; nothing is inferred from silence. */
28
+ export function scoringWaitingOn(snapshot) {
29
+ const panel = scoringPanel(snapshot).map(participant => participant.participantId);
30
+ const done = (phase, round = snapshot.round) => new Set(snapshot.completions.filter(entry => entry.phase === phase && entry.round === round).map(entry => entry.participantId));
31
+ switch (snapshot.phase) {
32
+ case 'nominating': {
33
+ const finished = done('nominating');
34
+ return panel.filter(id => !finished.has(id));
35
+ }
36
+ case 'voting': {
37
+ const candidates = candidateCriteria(snapshot);
38
+ if (!candidates.length)
39
+ return [];
40
+ return panel.filter(id => candidates.some(criterion => !snapshot.votes.some(vote => vote.criterionId === criterion.criterionId && vote.participantId === id && vote.round === snapshot.round)));
41
+ }
42
+ case 'scoring': {
43
+ const criteria = approvedCriteria(snapshot);
44
+ return panel.filter(id => criteria.some(criterion => !snapshot.scores.some(score => score.criterionId === criterion.criterionId && score.participantId === id && score.round === snapshot.debateRound)));
45
+ }
46
+ case 'debating': {
47
+ const open = snapshot.debates.filter(debate => debate.status === 'open' && debate.round === snapshot.debateRound);
48
+ // Whoever scored a contested criterion must defend, revise, or explicitly hold their position.
49
+ return panel.filter(id => open.some(debate => snapshot.scores.some(score => score.criterionId === debate.criterionId && score.participantId === id) && !debate.arguments.some(entry => entry.participantId === id)));
50
+ }
51
+ case 'rescoring': {
52
+ const contested = snapshot.debates.filter(debate => debate.round === snapshot.debateRound).map(debate => debate.criterionId);
53
+ return panel.filter(id => contested.some(criterionId => !snapshot.scores.some(score => score.criterionId === criterionId && score.participantId === id && score.round === snapshot.debateRound + 1)));
54
+ }
55
+ default: return [];
56
+ }
57
+ }
58
+ /**
59
+ * Ready means "the panel has finished this step", which presupposes a panel: with no reviewer every
60
+ * "everyone submitted" check is vacuously true, so /advance could walk nomination->voting->rubric with
61
+ * nobody in the room. A pending escalation parks the flow too - a human question that finalization
62
+ * overtakes is a question nobody ever answers. Both gates are bypassed only by an explicit forced advance.
63
+ */
64
+ export function isScoringReadyToAdvance(snapshot) {
65
+ if (snapshot.status !== 'active')
66
+ return false;
67
+ if (snapshot.phase === 'finalized')
68
+ return false;
69
+ if (!scoringPanel(snapshot).length)
70
+ return false;
71
+ if (snapshot.pendingEscalations > 0)
72
+ return false;
73
+ if (['consolidating', 'rubric_locked', 'analysis', 'awaiting_human'].includes(snapshot.phase))
74
+ return true;
75
+ if (snapshot.phase === 'nominating' && !candidateCriteria(snapshot).length && !scoringWaitingOn(snapshot).length)
76
+ return true;
77
+ return scoringWaitingOn(snapshot).length === 0;
78
+ }
79
+ /**
80
+ * A criterion passes when the approval ratio clears the threshold and at least two panelists
81
+ * approve, so a single reviewer can never impose a criterion. Weight is the median suggestion.
82
+ */
83
+ export function tallyVotes(snapshot, round = snapshot.round) {
84
+ return candidateCriteria(snapshot).map(criterion => {
85
+ const votes = snapshot.votes.filter(vote => vote.criterionId === criterion.criterionId && vote.round === round);
86
+ const approve = votes.filter(vote => vote.stance === 'approve'), reject = votes.filter(vote => vote.stance === 'reject');
87
+ const decided = approve.length + reject.length, ratio = decided ? approve.length / decided : 0;
88
+ const weights = approve.map(vote => vote.weight).filter((value) => typeof value === 'number' && value > 0);
89
+ return { criterionId: criterion.criterionId, approve: approve.length, reject: reject.length, abstain: votes.filter(vote => vote.stance === 'abstain').length,
90
+ ratio: round2(ratio), approved: ratio >= snapshot.policy.scoring.approvalThreshold && approve.length >= 2, weight: weights.length ? median(weights) : 1 };
91
+ });
92
+ }
93
+ /**
94
+ * Turns a vote round into a rubric. If the panel cannot land inside the configured size range,
95
+ * the last round falls back to the highest-approval criteria and records that policy, not the
96
+ * panel, made the call, so the report never pretends there was consensus.
97
+ */
98
+ export function lockRubric(snapshot) {
99
+ const tallies = tallyVotes(snapshot), { minCriteria, maxCriteria, maxVotingRounds } = snapshot.policy.scoring;
100
+ const passed = tallies.filter(tally => tally.approved).sort((a, b) => b.ratio - a.ratio || b.approve - a.approve);
101
+ const lastRound = snapshot.round >= maxVotingRounds;
102
+ const withinRange = passed.length >= minCriteria && passed.length <= maxCriteria;
103
+ if (withinRange)
104
+ return { criteria: normalizeWeights(passed), lockedBy: 'panel', reason: 'approved_by_panel', rejected: tallies.filter(tally => !tally.approved).map(tally => tally.criterionId) };
105
+ if (!lastRound)
106
+ return undefined;
107
+ const fallback = (passed.length ? passed : [...tallies].sort((a, b) => b.ratio - a.ratio || b.approve - a.approve)).slice(0, maxCriteria);
108
+ if (!fallback.length)
109
+ return undefined;
110
+ return { criteria: normalizeWeights(fallback), lockedBy: 'policy',
111
+ reason: passed.length > maxCriteria ? 'too_many_approved_criteria' : 'panel_never_reached_the_minimum',
112
+ rejected: tallies.filter(tally => !fallback.some(entry => entry.criterionId === tally.criterionId)).map(tally => tally.criterionId) };
113
+ }
114
+ export function normalizeWeights(tallies) {
115
+ const total = tallies.reduce((sum, tally) => sum + (tally.weight || 0), 0) || tallies.length;
116
+ return tallies.map(tally => ({ criterionId: tally.criterionId, weight: round2((tally.weight || 1) / total) }));
117
+ }
118
+ /**
119
+ * The scores that count for a criterion at `round`: each participant's latest submission at or before it.
120
+ * A rescore only covers the *contested* criteria, so an exact-round filter would drop every untouched
121
+ * criterion and finalize it at 0. Carrying the last score forward is what keeps the rubric intact.
122
+ */
123
+ export function effectiveScores(snapshot, criterionId, round) {
124
+ const latest = new Map();
125
+ for (const score of snapshot.scores) {
126
+ if (score.criterionId !== criterionId || score.round > round)
127
+ continue;
128
+ const current = latest.get(score.participantId);
129
+ if (!current || score.round >= current.round)
130
+ latest.set(score.participantId, score);
131
+ }
132
+ return [...latest.values()];
133
+ }
134
+ /** Measures disagreement per criterion for the current scoring round. */
135
+ export function analyse(snapshot, round = snapshot.debateRound) {
136
+ return approvedCriteria(snapshot).map(criterion => {
137
+ const entries = effectiveScores(snapshot, criterion.criterionId, round);
138
+ const values = entries.map(entry => entry.score);
139
+ if (!values.length)
140
+ return { criterionId: criterion.criterionId, scores: [], min: 0, max: 0, mean: 0, median: 0, stdev: 0, range: 0, contested: false };
141
+ const min = Math.min(...values), max = Math.max(...values);
142
+ return { criterionId: criterion.criterionId, scores: entries.map(entry => ({ participantId: entry.participantId, score: entry.score })),
143
+ min, max, mean: round2(mean(values)), median: round2(median(values)), stdev: round2(stdev(values)), range: round2(max - min),
144
+ contested: values.length > 1 && max - min > snapshot.policy.scoring.convergenceRange };
145
+ });
146
+ }
147
+ export function nextScoringPhase(snapshot, options = {}) {
148
+ if (snapshot.status !== 'active')
149
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Session is ${snapshot.status}`);
150
+ const pending = scoringWaitingOn(snapshot);
151
+ if (!isScoringReadyToAdvance(snapshot) && !options.forced) {
152
+ if (!scoringPanel(snapshot).length)
153
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'This scoring session has no reviewer on the panel yet; register one before advancing');
154
+ if (snapshot.pendingEscalations > 0)
155
+ throw flowError(COLLAB_ERRORS.wrongPhase, `${snapshot.pendingEscalations} escalation(s) still await a human ruling`);
156
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pending.length} participant(s): ${pending.join(', ')}`);
157
+ }
158
+ const base = { round: snapshot.round, debateRound: snapshot.debateRound, ...(options.forced && pending.length ? { skipped: pending } : {}) };
159
+ switch (snapshot.phase) {
160
+ case 'nominating': return { ...base, phase: 'consolidating', reason: 'nominations_complete' };
161
+ case 'consolidating': return { ...base, phase: 'voting', reason: 'candidates_ready' };
162
+ case 'voting': {
163
+ const rubric = lockRubric(snapshot);
164
+ if (rubric)
165
+ return { ...base, phase: 'rubric_locked', reason: rubric.lockedBy === 'panel' ? 'rubric_approved' : 'rubric_forced_by_policy', rubric };
166
+ const approved = tallyVotes(snapshot).filter(tally => tally.approved).length;
167
+ // Too few criteria means the panel needs more proposals; too many means it needs to narrow down.
168
+ return approved < snapshot.policy.scoring.minCriteria
169
+ ? { ...base, phase: 'nominating', round: snapshot.round + 1, reason: 'not_enough_approved_criteria' }
170
+ : { ...base, phase: 'voting', round: snapshot.round + 1, reason: 'too_many_approved_criteria' };
171
+ }
172
+ case 'rubric_locked': return { ...base, phase: 'scoring', reason: 'rubric_locked' };
173
+ case 'scoring': return { ...base, phase: 'analysis', reason: 'scores_submitted' };
174
+ case 'analysis': {
175
+ const contested = contestedCriteria(snapshot).map(entry => entry.criterionId);
176
+ if (!contested.length)
177
+ return { ...base, phase: 'finalized', reason: 'scores_converged' };
178
+ if (snapshot.debateRound >= snapshot.policy.scoring.maxDebateRounds)
179
+ return { ...base, phase: 'awaiting_human', reason: 'scores_still_contested', contested };
180
+ return { ...base, phase: 'debating', reason: 'contested_criteria', contested };
181
+ }
182
+ case 'debating': return { ...base, phase: 'rescoring', reason: 'debate_complete' };
183
+ case 'rescoring': return { ...base, phase: 'analysis', debateRound: snapshot.debateRound + 1, reason: 'rescored' };
184
+ case 'awaiting_human': return { ...base, phase: 'finalized', reason: 'human_ruled' };
185
+ default: throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
186
+ }
187
+ }
188
+ /**
189
+ * Produces the final rubric-weighted score. Rulings win over measurement; otherwise the median is
190
+ * used because it resists a single outlier. Dissenting scores are reported, never averaged away.
191
+ * A criterion that is still contested and got no ruling is reported as `forced`: calling that "converged"
192
+ * would let a forced advance close a session with a number the panel never agreed on.
193
+ */
194
+ export function finalizeScores(snapshot, rulings = {}) {
195
+ const analyses = analyse(snapshot), scale = snapshot.policy.scoring.scale, span = Math.max(1, scale.max - scale.min);
196
+ const criteria = approvedCriteria(snapshot).map(criterion => {
197
+ const analysis = analyses.find(entry => entry.criterionId === criterion.criterionId);
198
+ const ruled = rulings[criterion.criterionId];
199
+ const finalScore = ruled !== undefined ? ruled : analysis?.scores.length ? analysis.median : 0;
200
+ return { criterionId: criterion.criterionId, name: criterion.name, weight: criterion.weight ?? round2(1 / Math.max(1, approvedCriteria(snapshot).length)),
201
+ finalScore: round2(finalScore),
202
+ method: (ruled !== undefined ? 'human_ruled' : analysis?.contested ? 'forced' : snapshot.debateRound > 0 ? 'debated' : 'converged'),
203
+ spread: analysis?.range ?? 0, perReviewer: Object.fromEntries((analysis?.scores ?? []).map(entry => [entry.participantId, entry.score])) };
204
+ });
205
+ const weightTotal = criteria.reduce((sum, criterion) => sum + criterion.weight, 0) || 1;
206
+ const totalScore = round2(criteria.reduce((sum, criterion) => sum + criterion.finalScore * criterion.weight, 0) / weightTotal);
207
+ const spreads = analyses.filter(entry => entry.scores.length > 1).map(entry => entry.range / span);
208
+ const dissents = criteria.flatMap(criterion => Object.entries(criterion.perReviewer)
209
+ .map(([participantId, score]) => ({ participantId, criterionId: criterion.criterionId, score, distanceFromFinal: round2(Math.abs(score - criterion.finalScore)) }))
210
+ .filter(entry => entry.distanceFromFinal > snapshot.policy.scoring.convergenceRange / 2));
211
+ return { criteria, totalScore, scale: { min: scale.min, max: scale.max }, agreement: round2(spreads.length ? 1 - mean(spreads) : 1), dissents };
212
+ }
213
+ export function scoringProgress(snapshot) {
214
+ return { phase: snapshot.phase, round: snapshot.round, debateRound: snapshot.debateRound,
215
+ criteria: { candidates: candidateCriteria(snapshot).length, approved: approvedCriteria(snapshot).length },
216
+ contested: contestedCriteria(snapshot).map(entry => entry.criterionId),
217
+ waitingOn: scoringWaitingOn(snapshot), readyToAdvance: isScoringReadyToAdvance(snapshot) };
218
+ }
219
+ /** Blind scoring: nobody sees the distribution until every panelist has committed to a number. */
220
+ export function scoresSealed(snapshot) {
221
+ if (!snapshot.policy.scoring.blindScoring)
222
+ return false;
223
+ return ['scoring', 'rescoring'].includes(snapshot.phase) && scoringWaitingOn(snapshot).length > 0;
224
+ }
225
+ export function nominationsSealed(snapshot) {
226
+ return snapshot.phase === 'nominating' && scoringWaitingOn(snapshot).length > 0;
227
+ }
228
+ export function votesSealed(snapshot) {
229
+ return snapshot.phase === 'voting' && scoringWaitingOn(snapshot).length > 0;
230
+ }