@syntax-syllogism/aloop 0.5.3 → 0.6.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.
@@ -0,0 +1,67 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { rename, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ export const MANIFEST_VERSION = 1;
6
+
7
+ function canonicalize(value) {
8
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
9
+ return value;
10
+ }
11
+ if (typeof value === 'function' || value === undefined || typeof value === 'symbol') return undefined;
12
+ if (Array.isArray(value)) {
13
+ return value.map(canonicalize).filter((item) => item !== undefined);
14
+ }
15
+ if (typeof value === 'object') {
16
+ return Object.fromEntries(
17
+ Object.keys(value)
18
+ .sort()
19
+ .map((key) => [key, canonicalize(value[key])])
20
+ .filter(([, item]) => item !== undefined),
21
+ );
22
+ }
23
+ return String(value);
24
+ }
25
+
26
+ export function canonicalizeConfig(config) {
27
+ return canonicalize(config);
28
+ }
29
+
30
+ function sha256(text) {
31
+ return createHash('sha256').update(text).digest('hex');
32
+ }
33
+
34
+ export function hashText(text) {
35
+ return sha256(String(text));
36
+ }
37
+
38
+ export function hashConfig(config) {
39
+ return sha256(JSON.stringify(canonicalize(config)));
40
+ }
41
+
42
+ export class Manifest {
43
+ constructor(entries = []) {
44
+ this.entries = [...entries];
45
+ }
46
+
47
+ append(entry) {
48
+ const now = new Date().toISOString();
49
+ const recorded = {
50
+ ...entry,
51
+ startedAt: entry.startedAt ?? now,
52
+ completedAt: entry.completedAt ?? now,
53
+ };
54
+ this.entries.push(recorded);
55
+ return recorded;
56
+ }
57
+
58
+ async save(dir) {
59
+ const path = join(dir, 'manifest.json');
60
+ await writeFile(
61
+ `${path}.tmp`,
62
+ `${JSON.stringify({ manifestVersion: MANIFEST_VERSION, phases: this.entries }, null, 2)}\n`,
63
+ 'utf8',
64
+ );
65
+ await rename(`${path}.tmp`, path);
66
+ }
67
+ }
@@ -0,0 +1,251 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ function entriesFrom(manifest) {
5
+ if (Array.isArray(manifest)) return manifest;
6
+ if (Array.isArray(manifest?.entries)) return manifest.entries;
7
+ return Array.isArray(manifest?.phases) ? manifest.phases : [];
8
+ }
9
+
10
+ function finiteNumber(value) {
11
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
12
+ }
13
+
14
+ function sumKnown(entries, field) {
15
+ if (!entries.length || entries.some((entry) => finiteNumber(entry[field]) === null)) return null;
16
+ return entries.reduce((total, entry) => total + entry[field], 0);
17
+ }
18
+
19
+ function isUsageApplicable(entry) {
20
+ return !entry.budgetStall && entry.kind !== 'gate' && entry.role !== 'gate';
21
+ }
22
+
23
+ function usageCoverage(entries) {
24
+ const applicableEntries = entries.filter(isUsageApplicable);
25
+ const tokensReported = applicableEntries.filter((entry) => finiteNumber(entry.tokens) !== null).length;
26
+ const costReported = applicableEntries.filter((entry) => finiteNumber(entry.cost) !== null).length;
27
+ return {
28
+ applicableEntries: applicableEntries.length,
29
+ tokensReported,
30
+ costReported,
31
+ tokenCoverage: applicableEntries.length ? tokensReported / applicableEntries.length : null,
32
+ costCoverage: applicableEntries.length ? costReported / applicableEntries.length : null,
33
+ };
34
+ }
35
+
36
+ function roundFor(entry) {
37
+ if (Number.isInteger(entry.round) && entry.round > 0) return entry.round;
38
+ const artifact = (entry.artifacts ?? []).find((path) => /verdict-round-\d+\.json$/.test(path));
39
+ const match = artifact?.match(/verdict-round-(\d+)\.json$/);
40
+ return match ? Number(match[1]) : null;
41
+ }
42
+
43
+ function phaseSummary(name, entries) {
44
+ const attempts = entries.filter((entry) => entry.status !== 'skipped');
45
+ const stalled = attempts.filter((entry) => entry.status === 'stalled').length;
46
+ return {
47
+ phase: name,
48
+ entries: entries.length,
49
+ attempts: attempts.length,
50
+ completed: entries.filter((entry) => entry.status === 'completed').length,
51
+ stalled,
52
+ skipped: entries.filter((entry) => entry.status === 'skipped').length,
53
+ stallRate: attempts.length ? stalled / attempts.length : 0,
54
+ durationMs: attempts.reduce((total, entry) => total + (finiteNumber(entry.durationMs) ?? 0), 0),
55
+ tokens: sumKnown(attempts, 'tokens'),
56
+ cost: sumKnown(attempts, 'cost'),
57
+ };
58
+ }
59
+
60
+ function usageTotals(entries) {
61
+ const applicableEntries = entries.filter(isUsageApplicable);
62
+ return {
63
+ durationMs: entries.reduce((total, entry) => total + (finiteNumber(entry.durationMs) ?? 0), 0),
64
+ tokens: sumKnown(applicableEntries, 'tokens'),
65
+ cost: sumKnown(applicableEntries, 'cost'),
66
+ usageCoverage: usageCoverage(entries),
67
+ };
68
+ }
69
+
70
+ function reviewerMetrics(entries) {
71
+ const rounds = entries
72
+ .filter((entry) => entry.verdict && (entry.role === 'verdict' || entry.verdict.verdict))
73
+ .sort((a, b) => (roundFor(a) ?? Number.MAX_SAFE_INTEGER) - (roundFor(b) ?? Number.MAX_SAFE_INTEGER));
74
+ const approvals = rounds.filter((entry) => entry.verdict.verdict === 'APPROVED').length;
75
+ const caught = rounds.filter((entry) => entry.verdict.verdict === 'CHANGES_REQUESTED').length;
76
+ let findingsCleared = 0;
77
+ let repairRounds = 0;
78
+ for (let index = 0; index < rounds.length - 1; index += 1) {
79
+ const before = rounds[index].verdict.blocking?.length ?? 0;
80
+ const after = rounds[index + 1].verdict.blocking?.length ?? 0;
81
+ if (before > 0) {
82
+ repairRounds += 1;
83
+ findingsCleared += Math.max(0, before - after);
84
+ }
85
+ }
86
+ return {
87
+ rounds: rounds.length,
88
+ approvals,
89
+ changesRequested: caught,
90
+ catchRate: rounds.length ? caught / rounds.length : null,
91
+ findingsCleared,
92
+ repairRounds,
93
+ findingsClearedPerRound: repairRounds ? findingsCleared / repairRounds : null,
94
+ };
95
+ }
96
+
97
+ function convergenceMetrics(entries, reviewer) {
98
+ const reviewRounds = entries
99
+ .filter((entry) => entry.verdict && (entry.role === 'verdict' || entry.verdict.verdict))
100
+ .map(roundFor)
101
+ .filter((round) => round !== null);
102
+ const approval = entries
103
+ .filter((entry) => entry.verdict?.verdict === 'APPROVED')
104
+ .map(roundFor)
105
+ .filter((round) => round !== null)
106
+ .sort((a, b) => a - b)[0] ?? null;
107
+ const distribution = {};
108
+ if (approval !== null) distribution[approval] = 1;
109
+ return {
110
+ roundsToConverge: approval,
111
+ roundsToApproval: approval,
112
+ roundsObserved: reviewRounds.length || reviewer.rounds,
113
+ approved: approval !== null,
114
+ roundsToApprovalDistribution: distribution,
115
+ };
116
+ }
117
+
118
+ /** Compute run-level cost, convergence, and reviewer metrics from one manifest. */
119
+ export function computeRunMetrics(manifest) {
120
+ const entries = entriesFrom(manifest);
121
+ const names = [...new Set(entries.map((entry) => entry.phase).filter(Boolean))];
122
+ const phases = Object.fromEntries(names.map((name) => [
123
+ name,
124
+ phaseSummary(name, entries.filter((entry) => entry.phase === name)),
125
+ ]));
126
+ const totalEntries = entries.filter((entry) => entry.status !== 'skipped');
127
+ const total = usageTotals(totalEntries);
128
+ const reviewer = reviewerMetrics(entries);
129
+ return {
130
+ total,
131
+ phases,
132
+ phaseMetrics: phases,
133
+ convergence: convergenceMetrics(entries, reviewer),
134
+ reviewer,
135
+ stalled: names.filter((name) => phases[name].stalled > 0),
136
+ };
137
+ }
138
+
139
+ function aggregatePhase(name, summaries) {
140
+ const attempts = summaries.reduce((total, summary) => total + summary.attempts, 0);
141
+ const stalled = summaries.reduce((total, summary) => total + summary.stalled, 0);
142
+ return {
143
+ phase: name,
144
+ entries: summaries.reduce((total, summary) => total + summary.entries, 0),
145
+ attempts,
146
+ completed: summaries.reduce((total, summary) => total + summary.completed, 0),
147
+ stalled,
148
+ skipped: summaries.reduce((total, summary) => total + summary.skipped, 0),
149
+ stallRate: attempts ? stalled / attempts : 0,
150
+ durationMs: summaries.reduce((total, summary) => total + summary.durationMs, 0),
151
+ tokens: summaries.every((summary) => summary.tokens !== null)
152
+ ? summaries.reduce((total, summary) => total + summary.tokens, 0)
153
+ : null,
154
+ cost: summaries.every((summary) => summary.cost !== null)
155
+ ? summaries.reduce((total, summary) => total + summary.cost, 0)
156
+ : null,
157
+ };
158
+ }
159
+
160
+ function aggregateUsageCoverage(runMetrics) {
161
+ const coverage = runMetrics.reduce((totals, metrics) => ({
162
+ applicableEntries: totals.applicableEntries + metrics.total.usageCoverage.applicableEntries,
163
+ tokensReported: totals.tokensReported + metrics.total.usageCoverage.tokensReported,
164
+ costReported: totals.costReported + metrics.total.usageCoverage.costReported,
165
+ }), { applicableEntries: 0, tokensReported: 0, costReported: 0 });
166
+ return {
167
+ ...coverage,
168
+ tokenCoverage: coverage.applicableEntries ? coverage.tokensReported / coverage.applicableEntries : null,
169
+ costCoverage: coverage.applicableEntries ? coverage.costReported / coverage.applicableEntries : null,
170
+ };
171
+ }
172
+
173
+ /** Compute cross-run aggregates from manifest objects or raw phase arrays. */
174
+ export function computeAggregateMetrics(manifests) {
175
+ const runMetrics = manifests.map((manifest) => computeRunMetrics(manifest));
176
+ const phaseNames = [...new Set(runMetrics.flatMap((metrics) => Object.keys(metrics.phases)))];
177
+ const phases = Object.fromEntries(phaseNames.map((name) => [
178
+ name,
179
+ aggregatePhase(name, runMetrics.map((metrics) => metrics.phases[name]).filter(Boolean)),
180
+ ]));
181
+ const converged = runMetrics.filter((metrics) => metrics.convergence.roundsToConverge !== null);
182
+ const distribution = {};
183
+ for (const metrics of converged) {
184
+ const round = metrics.convergence.roundsToConverge;
185
+ distribution[round] = (distribution[round] ?? 0) + 1;
186
+ }
187
+ const reviewer = {
188
+ rounds: runMetrics.reduce((total, metrics) => total + metrics.reviewer.rounds, 0),
189
+ approvals: runMetrics.reduce((total, metrics) => total + metrics.reviewer.approvals, 0),
190
+ changesRequested: runMetrics.reduce((total, metrics) => total + metrics.reviewer.changesRequested, 0),
191
+ findingsCleared: runMetrics.reduce((total, metrics) => total + metrics.reviewer.findingsCleared, 0),
192
+ repairRounds: runMetrics.reduce((total, metrics) => total + metrics.reviewer.repairRounds, 0),
193
+ };
194
+ reviewer.catchRate = reviewer.rounds ? reviewer.changesRequested / reviewer.rounds : null;
195
+ reviewer.findingsClearedPerRound = reviewer.repairRounds
196
+ ? reviewer.findingsCleared / reviewer.repairRounds
197
+ : null;
198
+ return {
199
+ runs: runMetrics.length,
200
+ total: {
201
+ durationMs: runMetrics.reduce((total, metrics) => total + metrics.total.durationMs, 0),
202
+ tokens: runMetrics.every((metrics) => metrics.total.tokens !== null)
203
+ ? runMetrics.reduce((total, metrics) => total + metrics.total.tokens, 0)
204
+ : null,
205
+ cost: runMetrics.every((metrics) => metrics.total.cost !== null)
206
+ ? runMetrics.reduce((total, metrics) => total + metrics.total.cost, 0)
207
+ : null,
208
+ usageCoverage: aggregateUsageCoverage(runMetrics),
209
+ },
210
+ phases,
211
+ phaseMetrics: phases,
212
+ convergence: {
213
+ runs: runMetrics.length,
214
+ converged: converged.length,
215
+ stalled: runMetrics.length - converged.length,
216
+ convergenceRate: runMetrics.length ? converged.length / runMetrics.length : null,
217
+ roundsToApprovalDistribution: distribution,
218
+ roundsToConvergeDistribution: distribution,
219
+ averageRoundsToConverge: converged.length
220
+ ? converged.reduce((total, metrics) => total + metrics.convergence.roundsToConverge, 0) / converged.length
221
+ : null,
222
+ },
223
+ reviewer,
224
+ runMetrics,
225
+ };
226
+ }
227
+
228
+ export async function readRunManifest(runDir) {
229
+ try {
230
+ return JSON.parse(await readFile(join(runDir, 'manifest.json'), 'utf8'));
231
+ } catch (error) {
232
+ if (error.code === 'ENOENT') return null;
233
+ throw new Error(`Unable to read manifest in ${runDir}: ${error.message}`, { cause: error });
234
+ }
235
+ }
236
+
237
+ export async function readRunManifests(runsDir) {
238
+ let directories;
239
+ try {
240
+ directories = await readdir(runsDir, { withFileTypes: true });
241
+ } catch (error) {
242
+ if (error.code === 'ENOENT') return [];
243
+ throw error;
244
+ }
245
+ const runs = [];
246
+ for (const directory of directories.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
247
+ const manifest = await readRunManifest(join(runsDir, directory.name));
248
+ if (manifest) runs.push({ name: directory.name, manifest });
249
+ }
250
+ return runs;
251
+ }