@izagood/avcs 0.1.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.
Files changed (111) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +221 -0
  3. package/dist/api/repo.d.ts +616 -0
  4. package/dist/api/repo.d.ts.map +1 -0
  5. package/dist/api/repo.js +1772 -0
  6. package/dist/api/repo.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +660 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/concurrency/lease.d.ts +27 -0
  12. package/dist/concurrency/lease.d.ts.map +1 -0
  13. package/dist/concurrency/lease.js +63 -0
  14. package/dist/concurrency/lease.js.map +1 -0
  15. package/dist/core/canonical.d.ts +16 -0
  16. package/dist/core/canonical.d.ts.map +1 -0
  17. package/dist/core/canonical.js +66 -0
  18. package/dist/core/canonical.js.map +1 -0
  19. package/dist/core/cbor.d.ts +9 -0
  20. package/dist/core/cbor.d.ts.map +1 -0
  21. package/dist/core/cbor.js +213 -0
  22. package/dist/core/cbor.js.map +1 -0
  23. package/dist/core/clock.d.ts +18 -0
  24. package/dist/core/clock.d.ts.map +1 -0
  25. package/dist/core/clock.js +32 -0
  26. package/dist/core/clock.js.map +1 -0
  27. package/dist/core/identity.d.ts +34 -0
  28. package/dist/core/identity.d.ts.map +1 -0
  29. package/dist/core/identity.js +62 -0
  30. package/dist/core/identity.js.map +1 -0
  31. package/dist/hub/hubClient.d.ts +38 -0
  32. package/dist/hub/hubClient.d.ts.map +1 -0
  33. package/dist/hub/hubClient.js +157 -0
  34. package/dist/hub/hubClient.js.map +1 -0
  35. package/dist/hub/hubServer.d.ts +28 -0
  36. package/dist/hub/hubServer.d.ts.map +1 -0
  37. package/dist/hub/hubServer.js +365 -0
  38. package/dist/hub/hubServer.js.map +1 -0
  39. package/dist/index.d.ts +13 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/objects/types.d.ts +352 -0
  44. package/dist/objects/types.d.ts.map +1 -0
  45. package/dist/objects/types.js +17 -0
  46. package/dist/objects/types.js.map +1 -0
  47. package/dist/observe/logger.d.ts +27 -0
  48. package/dist/observe/logger.d.ts.map +1 -0
  49. package/dist/observe/logger.js +37 -0
  50. package/dist/observe/logger.js.map +1 -0
  51. package/dist/observe/metrics.d.ts +20 -0
  52. package/dist/observe/metrics.d.ts.map +1 -0
  53. package/dist/observe/metrics.js +42 -0
  54. package/dist/observe/metrics.js.map +1 -0
  55. package/dist/policy/owners.d.ts +5 -0
  56. package/dist/policy/owners.d.ts.map +1 -0
  57. package/dist/policy/owners.js +38 -0
  58. package/dist/policy/owners.js.map +1 -0
  59. package/dist/policy/reliability.d.ts +7 -0
  60. package/dist/policy/reliability.d.ts.map +1 -0
  61. package/dist/policy/reliability.js +41 -0
  62. package/dist/policy/reliability.js.map +1 -0
  63. package/dist/query/diff.d.ts +16 -0
  64. package/dist/query/diff.d.ts.map +1 -0
  65. package/dist/query/diff.js +32 -0
  66. package/dist/query/diff.js.map +1 -0
  67. package/dist/reducer/incremental.d.ts +2 -0
  68. package/dist/reducer/incremental.d.ts.map +1 -0
  69. package/dist/reducer/incremental.js +14 -0
  70. package/dist/reducer/incremental.js.map +1 -0
  71. package/dist/reducer/policy.d.ts +17 -0
  72. package/dist/reducer/policy.d.ts.map +1 -0
  73. package/dist/reducer/policy.js +143 -0
  74. package/dist/reducer/policy.js.map +1 -0
  75. package/dist/reducer/reducer.d.ts +155 -0
  76. package/dist/reducer/reducer.d.ts.map +1 -0
  77. package/dist/reducer/reducer.js +849 -0
  78. package/dist/reducer/reducer.js.map +1 -0
  79. package/dist/release/sbom.d.ts +7 -0
  80. package/dist/release/sbom.d.ts.map +1 -0
  81. package/dist/release/sbom.js +26 -0
  82. package/dist/release/sbom.js.map +1 -0
  83. package/dist/semantic/contract.d.ts +32 -0
  84. package/dist/semantic/contract.d.ts.map +1 -0
  85. package/dist/semantic/contract.js +150 -0
  86. package/dist/semantic/contract.js.map +1 -0
  87. package/dist/semantic/symbols.d.ts +45 -0
  88. package/dist/semantic/symbols.d.ts.map +1 -0
  89. package/dist/semantic/symbols.js +323 -0
  90. package/dist/semantic/symbols.js.map +1 -0
  91. package/dist/store/applyRedactions.d.ts +16 -0
  92. package/dist/store/applyRedactions.d.ts.map +1 -0
  93. package/dist/store/applyRedactions.js +60 -0
  94. package/dist/store/applyRedactions.js.map +1 -0
  95. package/dist/store/lock.d.ts +12 -0
  96. package/dist/store/lock.d.ts.map +1 -0
  97. package/dist/store/lock.js +63 -0
  98. package/dist/store/lock.js.map +1 -0
  99. package/dist/store/objectStore.d.ts +127 -0
  100. package/dist/store/objectStore.d.ts.map +1 -0
  101. package/dist/store/objectStore.js +549 -0
  102. package/dist/store/objectStore.js.map +1 -0
  103. package/dist/validation/repair.d.ts +31 -0
  104. package/dist/validation/repair.d.ts.map +1 -0
  105. package/dist/validation/repair.js +37 -0
  106. package/dist/validation/repair.js.map +1 -0
  107. package/dist/validation/runner.d.ts +20 -0
  108. package/dist/validation/runner.d.ts.map +1 -0
  109. package/dist/validation/runner.js +90 -0
  110. package/dist/validation/runner.js.map +1 -0
  111. package/package.json +83 -0
@@ -0,0 +1,849 @@
1
+ // The reducer: operation graph → materialized state.
2
+ //
3
+ // AVCS has no "merge". The current code is defined as
4
+ //
5
+ // state = reduce(base, operationDAG, decisions, policy, materializer)
6
+ //
7
+ // a pure, deterministic function. Same objects + same policy + same materializer
8
+ // on any replica ⇒ identical tree. Determinism does NOT depend on the order the
9
+ // caller passes objects in: reduce canonically sorts its inputs first (this is the
10
+ // fix for the filesystem-readdir-order bug). The algorithm is structured so the
11
+ // Phase-2 AST upgrade only swaps `keysOf` (the unit of contention) and `applyOp`.
12
+ import { sha256hex, canonicalize } from "../core/canonical.js";
13
+ import { evaluateOp } from "./policy.js";
14
+ import { spliceSymbol, renameSymbol, extractSymbol } from "../semantic/symbols.js";
15
+ import { ownersFor } from "../policy/owners.js";
16
+ // Status precedence when an op belongs to several contended keys (rename touches
17
+ // two). The strictest verdict across its groups wins.
18
+ const PRECEDENCE = {
19
+ proposed: 0,
20
+ accepted: 1,
21
+ superseded: 2,
22
+ needs_decision: 3,
23
+ rejected: 4,
24
+ validating: 0,
25
+ quarantined: 4,
26
+ };
27
+ function stricter(a, b) {
28
+ return (PRECEDENCE[a] ?? 0) >= (PRECEDENCE[b] ?? 0) ? a : b;
29
+ }
30
+ /**
31
+ * The contended keys an op occupies. A rename reads its source and writes its
32
+ * destination, so it contends on BOTH — otherwise a concurrent write to either
33
+ * path would slip through unmerged. note ops contend on nothing.
34
+ */
35
+ export function keysOf(op) {
36
+ const b = op.body;
37
+ switch (b.kind) {
38
+ case "put_file":
39
+ return [`file:${b.path ?? op.target.entityId}`];
40
+ case "delete_file":
41
+ return [`file:${b.path ?? op.target.entityId}`];
42
+ case "rename_file":
43
+ return [`file:${b.fromPath ?? op.target.entityId}`, `file:${b.path ?? op.target.entityId}`];
44
+ case "set_symbol":
45
+ // Symbol-granular: two edits to different symbols of the same file auto-merge.
46
+ return [`symbol:${b.path}#${b.symbolName}`];
47
+ case "rename_symbol":
48
+ // Contends on both the old and new symbol names within the file.
49
+ return [`symbol:${b.path}#${b.symbolName}`, `symbol:${b.path}#${b.newName}`];
50
+ case "move_symbol":
51
+ // Contends on the symbol at its source and its destination.
52
+ return [`symbol:${b.fromPath}#${b.symbolName}`, `symbol:${b.path}#${b.symbolName}`];
53
+ case "note":
54
+ return [];
55
+ }
56
+ }
57
+ /** Transitive causal-ancestor set for every op (over causalDeps within the set). */
58
+ function ancestry(ops) {
59
+ const byId = new Map(ops.map((o) => [o.oid, o]));
60
+ const memo = new Map();
61
+ const visit = (id) => {
62
+ const cached = memo.get(id);
63
+ if (cached)
64
+ return cached;
65
+ const set = new Set();
66
+ memo.set(id, set); // cycle guard (shouldn't happen in an append-only DAG)
67
+ const op = byId.get(id);
68
+ if (op) {
69
+ for (const dep of op.causalDeps) {
70
+ if (!byId.has(dep))
71
+ continue;
72
+ set.add(dep);
73
+ for (const a of visit(dep))
74
+ set.add(a);
75
+ }
76
+ }
77
+ return set;
78
+ };
79
+ for (const o of ops)
80
+ visit(o.oid);
81
+ return memo;
82
+ }
83
+ /** Does the op's declared effect respect its intent's constraints? */
84
+ function intentSatisfied(op, intents) {
85
+ const intent = intents.get(op.intentOid);
86
+ if (!intent)
87
+ return true;
88
+ // Structured constraints take precedence; NL constraints are a fallback heuristic.
89
+ if (intent.constraintKinds?.includes("forbid_public_api_break") && op.effects?.breaksPublicApi)
90
+ return false;
91
+ const nl = intent.constraints.join(" ").toLowerCase();
92
+ const forbidsApiBreak = nl.includes("api") &&
93
+ (nl.includes("금지") || nl.includes("유지") || nl.includes("no break") || nl.includes("unchanged"));
94
+ if (forbidsApiBreak && op.effects?.breaksPublicApi)
95
+ return false;
96
+ return true;
97
+ }
98
+ /**
99
+ * opOid → human verdict. Contradictory decisions are resolved by AUTHORITY first
100
+ * (docs/08 §4: a higher-authority decider wins), then canonical recency, then accept
101
+ * over reject within the same decision. `authority` maps decider id → role weight;
102
+ * absent ⇒ all weight 0, i.e. pure canonical-recency (the prior behavior).
103
+ */
104
+ function verdictMap(decisions, authority) {
105
+ const cmpKey = (a, b) => a[0] - b[0] || cmp(a[1], b[1]) || a[2] - b[2];
106
+ const best = new Map();
107
+ const consider = (oid, v, key) => {
108
+ const cur = best.get(oid);
109
+ if (!cur || cmpKey(key, cur.key) > 0)
110
+ best.set(oid, { v, key });
111
+ };
112
+ for (const d of decisions) {
113
+ const w = authority?.get(d.decidedBy.id) ?? 0;
114
+ for (const oid of d.rejectedOps)
115
+ consider(oid, "reject", [w, d.createdAt, 0]);
116
+ for (const oid of d.chosenOps)
117
+ consider(oid, "accept", [w, d.createdAt, 1]);
118
+ }
119
+ return new Map([...best].map(([oid, x]) => [oid, x.v]));
120
+ }
121
+ export function conflictIdFor(key) {
122
+ // Stable under head-set churn: keyed only on the contended entity.
123
+ return `conflict_${sha256hex(key).slice(0, 24)}`;
124
+ }
125
+ /**
126
+ * A whole-file op (put_file/delete/rename) and a set_symbol on the SAME file are keyed
127
+ * differently (file:… vs symbol:…#…), so the reducer's grouping never makes them
128
+ * contend. When they are CONCURRENT (neither a causal ancestor of the other), letting
129
+ * both apply makes the result depend on kahnOrder's lamport (authoring-order) tie-break
130
+ * — non-deterministic. This finds those pairs among ACCEPTED ops so the repo can hold
131
+ * them back (re-reduce) and surface a conflict. Ancestor relations (scaffold→edit) are
132
+ * intentionally not flagged.
133
+ */
134
+ export function detectCrossGranularity(ops, result) {
135
+ const anc = ancestry(ops);
136
+ const fileOf = (o) => {
137
+ const b = o.body;
138
+ if (b.kind === "put_file" || b.kind === "delete_file")
139
+ return b.path ?? o.target.entityId;
140
+ if (b.kind === "rename_file")
141
+ return b.fromPath ?? o.target.entityId;
142
+ if (b.kind === "set_symbol")
143
+ return b.path ?? null;
144
+ return null;
145
+ };
146
+ const isWhole = (o) => o.body.kind === "put_file" || o.body.kind === "delete_file" || o.body.kind === "rename_file";
147
+ const byFile = new Map();
148
+ for (const o of ops) {
149
+ if (result.statuses.get(o.oid) !== "accepted")
150
+ continue;
151
+ const f = fileOf(o);
152
+ if (f)
153
+ (byFile.get(f) ?? byFile.set(f, []).get(f)).push(o);
154
+ }
155
+ const out = [];
156
+ for (const [file, fops] of byFile) {
157
+ const whole = fops.filter(isWhole);
158
+ const syms = fops.filter((o) => o.body.kind === "set_symbol");
159
+ if (!whole.length || !syms.length)
160
+ continue;
161
+ const involved = new Set();
162
+ for (const w of whole)
163
+ for (const s of syms) {
164
+ const wId = w.oid;
165
+ const sId = s.oid;
166
+ if (!anc.get(wId)?.has(sId) && !anc.get(sId)?.has(wId)) {
167
+ involved.add(wId);
168
+ involved.add(sId);
169
+ }
170
+ }
171
+ if (involved.size)
172
+ out.push({ file, ops: [...involved].sort() });
173
+ }
174
+ return out;
175
+ }
176
+ export function reduce(input) {
177
+ return snapshotReduce(input).result;
178
+ }
179
+ /** Build the per-op evidence index used by `evaluateOp`. */
180
+ function buildEvByOp(evidence) {
181
+ const evByOp = new Map();
182
+ for (const e of evidence)
183
+ for (const opId of e.forOps)
184
+ (evByOp.get(opId) ?? evByOp.set(opId, []).get(opId)).push(e);
185
+ return evByOp;
186
+ }
187
+ /** A memoizing `evaluateOp` closure (pure given its captured inputs). */
188
+ function makeEvalOf(policy, intents, evByOp, reliability) {
189
+ const evalCache = new Map();
190
+ return (op, inConflict) => {
191
+ const cacheKey = `${op.oid}|${inConflict}`;
192
+ let e = evalCache.get(cacheKey);
193
+ if (!e) {
194
+ e = evaluateOp(policy, op, evByOp.get(op.oid) ?? [], inConflict, intentSatisfied(op, intents), reliability.get(op.actor.id) ?? 0);
195
+ evalCache.set(cacheKey, e);
196
+ }
197
+ return e;
198
+ };
199
+ }
200
+ /** Project decided statuses into a tree (+ treeHash, frontier headOps, synth blobs). */
201
+ /** The tree paths an op reads or writes — the unit of incremental-tree dirtying. */
202
+ function pathsOf(op) {
203
+ const b = op.body;
204
+ switch (b.kind) {
205
+ case "put_file":
206
+ case "delete_file":
207
+ case "set_symbol":
208
+ case "rename_symbol":
209
+ return [b.path ?? op.target.entityId];
210
+ case "rename_file":
211
+ return [b.fromPath ?? op.target.entityId, b.path ?? op.target.entityId];
212
+ case "move_symbol":
213
+ return [b.fromPath ?? op.target.entityId, b.path ?? op.target.entityId];
214
+ case "note":
215
+ return [];
216
+ }
217
+ }
218
+ /** rename_file/move_symbol read a SOURCE path's live content, coupling two paths. */
219
+ function isCrossPath(op) {
220
+ return op.body.kind === "rename_file" || op.body.kind === "move_symbol";
221
+ }
222
+ /** Keep only the synth-blob entries the final tree actually references (drops the
223
+ * intermediate splices that get overwritten). Makes synthBlobs a pure function of the
224
+ * final tree — which is what lets the incremental path reuse base entries exactly. */
225
+ function pruneSynth(tree, synth) {
226
+ const out = new Map();
227
+ for (const oid of tree.values()) {
228
+ const c = synth.get(oid);
229
+ if (c !== undefined)
230
+ out.set(oid, c);
231
+ }
232
+ return out;
233
+ }
234
+ const treeHashOf = (tree) => sha256hex(canonicalize(Object.fromEntries([...tree].sort())));
235
+ /** Frontier: accepted ops not an ancestor of another accepted op. An op is "covered"
236
+ * (non-head) iff it is in some accepted op's ancestor set — so collect the covered set
237
+ * in O(Σ ancestors) instead of the O(accepted²) all-pairs scan. Identical output. */
238
+ function frontier(ops, statuses, anc) {
239
+ const acceptedIds = new Set(ops.filter((o) => statuses.get(o.oid) === "accepted").map((o) => o.oid));
240
+ const covered = new Set();
241
+ for (const id of acceptedIds)
242
+ for (const a of anc.get(id) ?? [])
243
+ if (acceptedIds.has(a))
244
+ covered.add(a);
245
+ return [...acceptedIds].filter((id) => !covered.has(id));
246
+ }
247
+ function materializeProjection(ops, statuses, anc, materializeStatuses, blobContent) {
248
+ const projected = ops.filter((o) => materializeStatuses.has(statuses.get(o.oid)));
249
+ const ordered = kahnOrder(projected, anc);
250
+ const tree = new Map();
251
+ const synthBlobs = new Map();
252
+ for (const op of ordered)
253
+ applyOp(tree, op, blobContent, synthBlobs);
254
+ return { tree, treeHash: treeHashOf(tree), headOps: frontier(ops, statuses, anc), synthBlobs: pruneSynth(tree, synthBlobs) };
255
+ }
256
+ /**
257
+ * Incremental tree materialization (docs/11 A3). Reuse the base tree for every path
258
+ * whose contributing accepted-op subsequence is unchanged, and replay ONLY the ops that
259
+ * touch a dirty path — skipping the expensive symbol splices on untouched files (A1
260
+ * showed these dominate). `dirtyPaths` must over-approximate every path whose value can
261
+ * differ from base: paths of ops whose projected-membership changed (incl. new ops), and
262
+ * both paths of every projected cross-path op (rename/move read a source's live content).
263
+ * Replayed ops only ever read/write dirty paths, so a fresh replay tree + clean base
264
+ * entries reconstructs the full tree exactly. Equivalence is enforced by the A0 harness.
265
+ */
266
+ function materializeIncremental(ops, statuses, anc, materializeStatuses, blobContent, base, dirtyPaths) {
267
+ const projected = ops.filter((o) => materializeStatuses.has(statuses.get(o.oid)));
268
+ const ordered = kahnOrder(projected, anc);
269
+ // Replay only dirty-touching ops, in the SAME global order, into a fresh tree.
270
+ const replayTree = new Map();
271
+ const replaySynth = new Map();
272
+ for (const op of ordered) {
273
+ if (pathsOf(op).some((p) => dirtyPaths.has(p)))
274
+ applyOp(replayTree, op, blobContent, replaySynth);
275
+ }
276
+ // Final tree = clean base paths (not dirty) + replayed dirty paths.
277
+ const tree = new Map();
278
+ for (const [p, oid] of base.tree)
279
+ if (!dirtyPaths.has(p))
280
+ tree.set(p, oid);
281
+ for (const [p, oid] of replayTree)
282
+ tree.set(p, oid);
283
+ // synthBlobs: union of base (clean paths' synths) + replay, pruned to the final tree.
284
+ const synthCandidates = new Map([...base.synthBlobs, ...replaySynth]);
285
+ return { tree, treeHash: treeHashOf(tree), headOps: frontier(ops, statuses, anc), synthBlobs: pruneSynth(tree, synthCandidates) };
286
+ }
287
+ export function snapshotReduce(input) {
288
+ const { intents, policy } = input;
289
+ const materializeStatuses = new Set(input.materializeStatuses ?? ["accepted"]);
290
+ // ── Canonical input ordering (determinism independent of caller order). ──
291
+ const ops = [...input.ops].sort((a, b) => a.lamport - b.lamport || cmp(a.oid, b.oid));
292
+ const decisions = [...input.decisions].sort((a, b) => cmp(a.createdAt, b.createdAt) || cmp(a.oid, b.oid));
293
+ const evidence = [...input.evidence].sort((a, b) => cmp(a.createdAt, b.createdAt) || cmp(a.oid, b.oid));
294
+ const statuses = new Map();
295
+ for (const o of ops)
296
+ statuses.set(o.oid, "proposed");
297
+ const anc = ancestry(ops);
298
+ const verdicts = verdictMap(decisions, input.authority);
299
+ const evByOp = buildEvByOp(evidence);
300
+ // Group ops by every key they contend on (note ops get a private singleton group).
301
+ const groups = new Map();
302
+ for (const o of ops) {
303
+ const keys = keysOf(o);
304
+ const ks = keys.length ? keys : [`op:${o.oid}`];
305
+ for (const k of ks)
306
+ (groups.get(k) ?? groups.set(k, []).get(k)).push(o);
307
+ }
308
+ const reliability = input.reliability ?? new Map();
309
+ const evalOf = makeEvalOf(policy, intents, evByOp, reliability);
310
+ // Decide each group locally; aggregate the strictest verdict per op. Capture each
311
+ // group's emitted conflicts/autoDecisions separately so an incremental re-reduce can
312
+ // reuse a clean group verbatim (incremental.ts), while the final arrays preserve the
313
+ // exact group-iteration order.
314
+ const conflicts = [];
315
+ const autoDecisions = [];
316
+ const perKey = new Map();
317
+ const groupOrder = [];
318
+ const groupMembers = new Map();
319
+ for (const [key, groupOps] of groups) {
320
+ groupOrder.push(key);
321
+ groupMembers.set(key, groupOps.map((o) => o.oid));
322
+ const kc = [];
323
+ const ka = [];
324
+ const local = decideGroup(key, groupOps, anc, verdicts, evalOf, policy, kc, ka);
325
+ perKey.set(key, { local, conflicts: kc, autoDecisions: ka });
326
+ for (const [oid, st] of local)
327
+ statuses.set(oid, stricter(statuses.get(oid) ?? "proposed", st));
328
+ conflicts.push(...kc);
329
+ autoDecisions.push(...ka);
330
+ }
331
+ // Phase 5: annotate needs_human conflicts with the scope owners who should decide.
332
+ // Mutates the same Conflict objects held in `perKey`, so the cache stays consistent.
333
+ for (const c of conflicts) {
334
+ const o = ownersFor(c.key, policy.owners ?? []);
335
+ if (o.length)
336
+ c.requiredOwners = o;
337
+ }
338
+ // A note op is never grouped on an entity; promote any that stayed "proposed".
339
+ for (const o of ops)
340
+ if (keysOf(o).length === 0 && statuses.get(o.oid) === "proposed")
341
+ statuses.set(o.oid, "accepted");
342
+ const { tree, treeHash, headOps, synthBlobs } = materializeProjection(ops, statuses, anc, materializeStatuses, input.blobContent ?? new Map());
343
+ const result = { tree, treeHash, statuses, conflicts, autoDecisions, semanticConflicts: [], headOps, synthBlobs };
344
+ const stats = { groupsTotal: groups.size, groupsRecomputed: groups.size, groupsReused: 0, dirtyKeys: groups.size };
345
+ return { input, result, perKey, groupOrder, groupMembers, stats };
346
+ }
347
+ /** Thrown when an incremental re-reduce's preconditions don't hold; the caller must
348
+ * fall back to a full `reduce`. Never indicates a correctness failure — only that the
349
+ * fast path doesn't apply (policy/authority/materializeStatuses changed, or `next` is
350
+ * not an append-superset of the snapshot's input). */
351
+ export class NonIncrementalError extends Error {
352
+ constructor(reason) {
353
+ super(`non-incremental: ${reason}`);
354
+ this.name = "NonIncrementalError";
355
+ }
356
+ }
357
+ function sameStatusSet(a, b) {
358
+ const sa = new Set(a ?? ["accepted"]);
359
+ const sb = new Set(b ?? ["accepted"]);
360
+ if (sa.size !== sb.size)
361
+ return false;
362
+ for (const x of sa)
363
+ if (!sb.has(x))
364
+ return false;
365
+ return true;
366
+ }
367
+ function sameNumberMap(a, b) {
368
+ const ma = a ?? new Map();
369
+ const mb = b ?? new Map();
370
+ const keys = new Set([...ma.keys(), ...mb.keys()]);
371
+ for (const k of keys)
372
+ if ((ma.get(k) ?? 0) !== (mb.get(k) ?? 0))
373
+ return false;
374
+ return true;
375
+ }
376
+ /**
377
+ * Incremental re-reduce (docs/11 Track A). Given a prior `snapshotReduce` and a `next`
378
+ * input that is an APPEND-SUPERSET of the snapshot's input (same policy/authority/
379
+ * materializeStatuses; ops/decisions/evidence only added), recompute only the groups
380
+ * whose decision could have changed (the "dirty set") and reuse every clean group's
381
+ * cached decision verbatim. The returned result is structurally identical to
382
+ * `reduce(next)` — this is the invariant the differential harness enforces.
383
+ *
384
+ * Dirty keys (see docs/11): keys of new ops; keys of ops targeted by new decisions or
385
+ * new evidence (these can flip blocked/accept regardless of contention); keys whose
386
+ * group membership changed or are brand new; and the keys of any op whose actor's
387
+ * reliability changed (a needs_human conflict embeds the op's reliability-derived score,
388
+ * so reliability changes are not gated by contention here — A1 may tighten this).
389
+ *
390
+ * Throws {@link NonIncrementalError} when the preconditions don't hold; the caller then
391
+ * falls back to a full reduce. tree/headOps are rebuilt fully (cheap, in-memory) in A0;
392
+ * A3 will make the tree update incremental too.
393
+ */
394
+ export function reduceIncremental(snap, next) {
395
+ const prev = snap.input;
396
+ // ── Preconditions: invariants that the clean-group reuse assumes. ──
397
+ if (!sameStatusSet(prev.materializeStatuses, next.materializeStatuses))
398
+ throw new NonIncrementalError("materializeStatuses changed");
399
+ if (!sameNumberMap(prev.authority, next.authority))
400
+ throw new NonIncrementalError("authority changed");
401
+ if (prev.policy !== next.policy && canonicalize(prev.policy) !== canonicalize(next.policy)) {
402
+ throw new NonIncrementalError("policy changed");
403
+ }
404
+ const materializeStatuses = new Set(next.materializeStatuses ?? ["accepted"]);
405
+ const ops = [...next.ops].sort((a, b) => a.lamport - b.lamport || cmp(a.oid, b.oid));
406
+ const decisions = [...next.decisions].sort((a, b) => cmp(a.createdAt, b.createdAt) || cmp(a.oid, b.oid));
407
+ const evidence = [...next.evidence].sort((a, b) => cmp(a.createdAt, b.createdAt) || cmp(a.oid, b.oid));
408
+ // ── Require next ⊇ prev (append-only); else the fast path can't apply. ──
409
+ const nextOpIds = new Set(ops.map((o) => o.oid));
410
+ for (const o of prev.ops)
411
+ if (!nextOpIds.has(o.oid))
412
+ throw new NonIncrementalError("an op was removed");
413
+ const nextDecIds = new Set(decisions.map((d) => d.oid));
414
+ for (const d of prev.decisions)
415
+ if (!nextDecIds.has(d.oid))
416
+ throw new NonIncrementalError("a decision was removed");
417
+ const nextEvIds = new Set(evidence.map((e) => e.oid));
418
+ for (const e of prev.evidence)
419
+ if (!nextEvIds.has(e.oid))
420
+ throw new NonIncrementalError("an evidence was removed");
421
+ const statuses = new Map();
422
+ for (const o of ops)
423
+ statuses.set(o.oid, "proposed");
424
+ const anc = ancestry(ops);
425
+ const verdicts = verdictMap(decisions, next.authority);
426
+ const evByOp = buildEvByOp(evidence);
427
+ const reliability = next.reliability ?? new Map();
428
+ const evalOf = makeEvalOf(next.policy, next.intents, evByOp, reliability);
429
+ const opById = new Map(ops.map((o) => [o.oid, o]));
430
+ // ── Groups for `next` (insertion order = canonical sorted-op order). ──
431
+ const groups = new Map();
432
+ for (const o of ops) {
433
+ const keys = keysOf(o);
434
+ const ks = keys.length ? keys : [`op:${o.oid}`];
435
+ for (const k of ks)
436
+ (groups.get(k) ?? groups.set(k, []).get(k)).push(o);
437
+ }
438
+ // ── Dirty-key set. ──
439
+ const prevOpIds = new Set(prev.ops.map((o) => o.oid));
440
+ const prevDecIds = new Set(prev.decisions.map((d) => d.oid));
441
+ const prevEvIds = new Set(prev.evidence.map((e) => e.oid));
442
+ const prevRel = prev.reliability ?? new Map();
443
+ const changedActors = new Set();
444
+ for (const a of new Set([...prevRel.keys(), ...reliability.keys()]))
445
+ if ((prevRel.get(a) ?? 0) !== (reliability.get(a) ?? 0))
446
+ changedActors.add(a);
447
+ const dirty = new Set();
448
+ const dirtyKeysOfOp = (oid) => {
449
+ const o = opById.get(oid);
450
+ if (!o)
451
+ return;
452
+ const ks = keysOf(o);
453
+ if (ks.length)
454
+ for (const k of ks)
455
+ dirty.add(k);
456
+ else
457
+ dirty.add(`op:${oid}`);
458
+ };
459
+ const deltaOpIds = new Set(ops.filter((o) => !prevOpIds.has(o.oid)).map((o) => o.oid));
460
+ for (const oid of deltaOpIds)
461
+ dirtyKeysOfOp(oid); // new ops
462
+ // Ancestry extension: a delta op can be a (transitive) causal ancestor of a PRE-EXISTING
463
+ // op — sync delivers ops out of causal order (base={X} with X→Y missing, next adds Y).
464
+ // That changes the ancestor relations inside any group whose member is downstream of a
465
+ // delta op, so those groups must be recomputed even though their membership is unchanged.
466
+ if (deltaOpIds.size)
467
+ for (const m of ops) {
468
+ const a = anc.get(m.oid);
469
+ if (!a)
470
+ continue;
471
+ for (const d of deltaOpIds)
472
+ if (a.has(d)) {
473
+ dirtyKeysOfOp(m.oid);
474
+ break;
475
+ }
476
+ }
477
+ for (const d of decisions)
478
+ if (!prevDecIds.has(d.oid))
479
+ for (const oid of [...d.chosenOps, ...d.rejectedOps])
480
+ dirtyKeysOfOp(oid); // new decisions
481
+ for (const e of evidence)
482
+ if (!prevEvIds.has(e.oid))
483
+ for (const oid of e.forOps)
484
+ dirtyKeysOfOp(oid); // new evidence
485
+ // membership change / brand-new key, and reliability-changed actors.
486
+ for (const [k, members] of groups) {
487
+ const prevMembers = snap.groupMembers.get(k);
488
+ if (!prevMembers) {
489
+ dirty.add(k);
490
+ continue;
491
+ }
492
+ if (prevMembers.length !== members.length) {
493
+ dirty.add(k);
494
+ continue;
495
+ }
496
+ const cur = new Set(members.map((o) => o.oid));
497
+ if (prevMembers.some((id) => !cur.has(id))) {
498
+ dirty.add(k);
499
+ continue;
500
+ }
501
+ if (changedActors.size && members.some((o) => changedActors.has(o.actor.id)))
502
+ dirty.add(k);
503
+ }
504
+ // ── Decide each group: recompute the dirty ones, reuse the clean ones. ──
505
+ const conflicts = [];
506
+ const autoDecisions = [];
507
+ const perKey = new Map();
508
+ const groupOrder = [];
509
+ const groupMembers = new Map();
510
+ let recomputed = 0;
511
+ let reused = 0;
512
+ for (const [key, groupOps] of groups) {
513
+ groupOrder.push(key);
514
+ groupMembers.set(key, groupOps.map((o) => o.oid));
515
+ let dec;
516
+ if (dirty.has(key)) {
517
+ const kc = [];
518
+ const ka = [];
519
+ const local = decideGroup(key, groupOps, anc, verdicts, evalOf, next.policy, kc, ka);
520
+ dec = { local, conflicts: kc, autoDecisions: ka };
521
+ recomputed++;
522
+ }
523
+ else {
524
+ dec = snap.perKey.get(key); // clean group: inputs unchanged ⇒ decision unchanged
525
+ reused++;
526
+ }
527
+ perKey.set(key, dec);
528
+ for (const [oid, st] of dec.local)
529
+ statuses.set(oid, stricter(statuses.get(oid) ?? "proposed", st));
530
+ conflicts.push(...dec.conflicts);
531
+ autoDecisions.push(...dec.autoDecisions);
532
+ }
533
+ for (const c of conflicts) {
534
+ const o = ownersFor(c.key, next.policy.owners ?? []);
535
+ if (o.length)
536
+ c.requiredOwners = o;
537
+ }
538
+ for (const o of ops)
539
+ if (keysOf(o).length === 0 && statuses.get(o.oid) === "proposed")
540
+ statuses.set(o.oid, "accepted");
541
+ // ── Dirty PATHS for the incremental tree (A3). A path may differ from base if an op
542
+ // touching it changed projected-membership (incl. new ops), or it is read/written by a
543
+ // projected cross-path op (rename/move carry a source's live content to a dest). ──
544
+ const projectedNow = (oid) => materializeStatuses.has(statuses.get(oid));
545
+ const projectedBase = (oid) => {
546
+ const s = snap.result.statuses.get(oid);
547
+ return s !== undefined && materializeStatuses.has(s);
548
+ };
549
+ const ancestryExtended = (oid) => {
550
+ const a = anc.get(oid);
551
+ if (!a)
552
+ return false;
553
+ for (const d of deltaOpIds)
554
+ if (a.has(d))
555
+ return true; // a delta op became this op's ancestor
556
+ return false;
557
+ };
558
+ const dirtyPaths = new Set();
559
+ for (const o of ops) {
560
+ const oid = o.oid;
561
+ // Membership change (incl. new ops) or ancestry extension can change a path's value
562
+ // or the order its ops apply in (ancestry extension guards against lamport that is
563
+ // not consistent with causality — real repo ops always are, but reduce() is pure).
564
+ if (projectedNow(oid) !== projectedBase(oid) || ancestryExtended(oid))
565
+ for (const p of pathsOf(o))
566
+ dirtyPaths.add(p);
567
+ else if (projectedNow(oid) && isCrossPath(o))
568
+ for (const p of pathsOf(o))
569
+ dirtyPaths.add(p);
570
+ }
571
+ const { tree, treeHash, headOps, synthBlobs } = materializeIncremental(ops, statuses, anc, materializeStatuses, next.blobContent ?? new Map(), snap.result, dirtyPaths);
572
+ const result = { tree, treeHash, statuses, conflicts, autoDecisions, semanticConflicts: [], headOps, synthBlobs };
573
+ const stats = { groupsTotal: groups.size, groupsRecomputed: recomputed, groupsReused: reused, dirtyKeys: dirty.size };
574
+ return { input: next, result, perKey, groupOrder, groupMembers, stats };
575
+ }
576
+ const mapToEntries = (m) => [...m];
577
+ const entriesToMap = (e) => new Map(e);
578
+ export function serializeSnapshot(snap) {
579
+ const inp = snap.input;
580
+ const r = snap.result;
581
+ return {
582
+ v: 1,
583
+ input: {
584
+ ops: inp.ops.map((o) => o.oid),
585
+ decisions: inp.decisions.map((d) => d.oid),
586
+ evidence: inp.evidence.map((e) => e.oid),
587
+ reliability: mapToEntries(inp.reliability ?? new Map()),
588
+ authority: mapToEntries(inp.authority ?? new Map()),
589
+ policy: inp.policy,
590
+ materializeStatuses: inp.materializeStatuses ?? null,
591
+ },
592
+ result: {
593
+ tree: mapToEntries(r.tree),
594
+ treeHash: r.treeHash,
595
+ statuses: mapToEntries(r.statuses),
596
+ conflicts: r.conflicts,
597
+ autoDecisions: r.autoDecisions,
598
+ semanticConflicts: r.semanticConflicts,
599
+ headOps: r.headOps,
600
+ synthBlobs: mapToEntries(r.synthBlobs),
601
+ },
602
+ perKey: [...snap.perKey].map(([k, d]) => [k, { local: mapToEntries(d.local), conflicts: d.conflicts, autoDecisions: d.autoDecisions }]),
603
+ groupOrder: snap.groupOrder,
604
+ groupMembers: mapToEntries(snap.groupMembers),
605
+ };
606
+ }
607
+ export function deserializeSnapshot(raw) {
608
+ const s = raw;
609
+ const inp = s.input;
610
+ // Stub ops/decisions/evidence: reduceIncremental only reads their `.oid`.
611
+ const stub = (oid) => ({ oid });
612
+ const input = {
613
+ ops: inp.ops.map(stub),
614
+ decisions: inp.decisions.map((oid) => ({ oid })),
615
+ evidence: inp.evidence.map((oid) => ({ oid })),
616
+ intents: new Map(),
617
+ policy: inp.policy,
618
+ materializeStatuses: inp.materializeStatuses ?? undefined,
619
+ reliability: entriesToMap(inp.reliability),
620
+ authority: entriesToMap(inp.authority),
621
+ };
622
+ const r = s.result;
623
+ const result = {
624
+ tree: entriesToMap(r.tree),
625
+ treeHash: r.treeHash,
626
+ statuses: entriesToMap(r.statuses),
627
+ conflicts: r.conflicts,
628
+ autoDecisions: r.autoDecisions,
629
+ semanticConflicts: r.semanticConflicts,
630
+ headOps: r.headOps,
631
+ synthBlobs: entriesToMap(r.synthBlobs),
632
+ };
633
+ const perKey = new Map(s.perKey.map(([k, d]) => [k, { local: entriesToMap(d.local), conflicts: d.conflicts, autoDecisions: d.autoDecisions }]));
634
+ const stats = { groupsTotal: perKey.size, groupsRecomputed: 0, groupsReused: perKey.size, dirtyKeys: 0 };
635
+ return { input, result, perKey, groupOrder: s.groupOrder, groupMembers: entriesToMap(s.groupMembers), stats };
636
+ }
637
+ function decideGroup(key, groupOps, anc, verdicts, evalOf, policy, conflicts, autoDecisions) {
638
+ const out = new Map();
639
+ // Frontier of this group: ops not an ancestor of another group member.
640
+ const heads = groupOps.filter((o) => {
641
+ for (const other of groupOps)
642
+ if (other !== o && anc.get(other.oid)?.has(o.oid))
643
+ return false;
644
+ return true;
645
+ });
646
+ for (const o of groupOps)
647
+ if (!heads.includes(o))
648
+ out.set(o.oid, "superseded");
649
+ // 1) Honor explicit human decisions first (H1) — globally, regardless of grouping.
650
+ const forcedAccept = heads.filter((o) => verdicts.get(o.oid) === "accept");
651
+ const forcedReject = heads.filter((o) => verdicts.get(o.oid) === "reject");
652
+ for (const o of forcedReject)
653
+ out.set(o.oid, "rejected");
654
+ if (forcedAccept.length) {
655
+ for (const o of forcedAccept)
656
+ out.set(o.oid, "accepted");
657
+ for (const o of heads)
658
+ if (!forcedAccept.includes(o))
659
+ out.set(o.oid, "rejected");
660
+ return out;
661
+ }
662
+ const remaining = heads.filter((o) => !forcedReject.includes(o));
663
+ if (remaining.length === 0)
664
+ return out;
665
+ const inConflict = remaining.length > 1;
666
+ // 2) Single uncontended head.
667
+ if (remaining.length === 1) {
668
+ const op = remaining[0];
669
+ const ev = evalOf(op, false);
670
+ if (ev.blocked)
671
+ out.set(op.oid, "rejected");
672
+ else if (ev.requiresHuman) {
673
+ out.set(op.oid, "needs_decision");
674
+ conflicts.push(makeConflict(key, "needs_human", [op], (o) => evalOf(o, false), null, ev.notes.join("; ")));
675
+ }
676
+ else
677
+ out.set(op.oid, "accepted");
678
+ return out;
679
+ }
680
+ // 3) Contended: policy reduction.
681
+ const blocked = remaining.filter((o) => evalOf(o, inConflict).blocked);
682
+ for (const o of blocked)
683
+ out.set(o.oid, "rejected");
684
+ const viable = remaining.filter((o) => !blocked.includes(o));
685
+ const needsHuman = viable.some((o) => evalOf(o, inConflict).requiresHuman);
686
+ const ranked = [...viable].sort((a, b) => {
687
+ const d = evalOf(b, inConflict).score - evalOf(a, inConflict).score;
688
+ if (d !== 0)
689
+ return d;
690
+ return a.lamport - b.lamport || cmp(a.oid, b.oid); // lamport is a tie-break only
691
+ });
692
+ const top = ranked[0];
693
+ const topScore = top ? evalOf(top, inConflict).score : -Infinity;
694
+ const tie = ranked.filter((o) => evalOf(o, inConflict).score === topScore).length > 1;
695
+ if (!top || needsHuman || tie) {
696
+ for (const o of viable)
697
+ out.set(o.oid, "needs_decision");
698
+ conflicts.push(makeConflict(key, needsHuman ? "needs_human" : "concurrent_write", viable, (o) => evalOf(o, inConflict), needsHuman ? null : top ?? null, needsHuman ? "requires human decision per policy" : tie ? "score tie — needs a human" : "no viable op"));
699
+ return out;
700
+ }
701
+ // Policy auto-decision — recorded so the merge is auditable (H4).
702
+ out.set(top.oid, "accepted");
703
+ const losers = viable.filter((o) => o !== top).map((o) => o.oid);
704
+ for (const id of losers)
705
+ out.set(id, "rejected");
706
+ autoDecisions.push({
707
+ key,
708
+ conflictId: conflictIdFor(key),
709
+ chosenOp: top.oid,
710
+ rejectedOps: losers,
711
+ reason: evalOf(top, inConflict).notes.join("; ") || "highest policy score",
712
+ policyVersion: policy.version,
713
+ });
714
+ return out;
715
+ }
716
+ function makeConflict(key, kind, ops, evalOf, recommended, reason) {
717
+ return {
718
+ id: conflictIdFor(key),
719
+ key,
720
+ kind,
721
+ reason,
722
+ recommendedOp: recommended ? recommended.oid : null,
723
+ options: ops.map((o) => {
724
+ const ev = evalOf(o);
725
+ return {
726
+ opOid: o.oid,
727
+ actor: o.actor.id,
728
+ purpose: o.declaredPurpose,
729
+ evidence: ev.notes,
730
+ score: ev.score,
731
+ blocked: ev.blocked,
732
+ requiresHuman: ev.requiresHuman,
733
+ };
734
+ }),
735
+ };
736
+ }
737
+ /** Deterministic topological sort (Kahn): ready set ordered by (lamport, oid). */
738
+ function kahnOrder(ops, anc) {
739
+ const ids = new Set(ops.map((o) => o.oid));
740
+ const byId = new Map(ops.map((o) => [o.oid, o]));
741
+ const indeg = new Map();
742
+ const edges = new Map(); // dep → dependents
743
+ for (const o of ops) {
744
+ const deps = o.causalDeps.filter((d) => ids.has(d));
745
+ indeg.set(o.oid, deps.length);
746
+ for (const d of deps)
747
+ (edges.get(d) ?? edges.set(d, []).get(d)).push(o.oid);
748
+ }
749
+ const ready = ops
750
+ .filter((o) => (indeg.get(o.oid) ?? 0) === 0)
751
+ .sort((a, b) => a.lamport - b.lamport || cmp(a.oid, b.oid));
752
+ const order = [];
753
+ while (ready.length) {
754
+ const op = ready.shift();
755
+ order.push(op);
756
+ for (const dep of edges.get(op.oid) ?? []) {
757
+ const n = (indeg.get(dep) ?? 0) - 1;
758
+ indeg.set(dep, n);
759
+ if (n === 0) {
760
+ const o = byId.get(dep);
761
+ // insert keeping (lamport, oid) order
762
+ let i = ready.length;
763
+ while (i > 0 && (ready[i - 1].lamport > o.lamport || (ready[i - 1].lamport === o.lamport && cmp(ready[i - 1].oid, o.oid) > 0)))
764
+ i--;
765
+ ready.splice(i, 0, o);
766
+ }
767
+ }
768
+ }
769
+ // Any leftover (cycle — shouldn't happen) appended deterministically.
770
+ if (order.length < ops.length) {
771
+ const seen = new Set(order.map((o) => o.oid));
772
+ for (const o of [...ops].sort((a, b) => a.lamport - b.lamport || cmp(a.oid, b.oid)))
773
+ if (!seen.has(o.oid))
774
+ order.push(o);
775
+ }
776
+ return order;
777
+ }
778
+ function applyOp(tree, op, blobContent, synthBlobs) {
779
+ const b = op.body;
780
+ const resolve = (oid) => synthBlobs.get(oid) ?? blobContent.get(oid) ?? "";
781
+ switch (b.kind) {
782
+ case "put_file":
783
+ if (b.path && b.blobOid)
784
+ tree.set(b.path, b.blobOid);
785
+ break;
786
+ case "delete_file":
787
+ tree.delete(b.path ?? op.target.entityId);
788
+ break;
789
+ case "rename_file":
790
+ if (b.fromPath && b.path) {
791
+ const blob = tree.get(b.fromPath);
792
+ if (blob !== undefined) {
793
+ tree.delete(b.fromPath);
794
+ tree.set(b.path, blob);
795
+ }
796
+ }
797
+ break;
798
+ case "set_symbol": {
799
+ if (!b.path || !b.symbolName || !b.blobOid)
800
+ break;
801
+ const currentOid = tree.get(b.path);
802
+ const current = currentOid !== undefined ? resolve(currentOid) : "";
803
+ const merged = spliceSymbol(current, b.symbolName, resolve(b.blobOid));
804
+ const synthOid = `blob_${sha256hex(merged).slice(0, 32)}`;
805
+ synthBlobs.set(synthOid, merged);
806
+ tree.set(b.path, synthOid);
807
+ break;
808
+ }
809
+ case "rename_symbol": {
810
+ if (!b.path || !b.symbolName || !b.newName)
811
+ break;
812
+ const currentOid = tree.get(b.path);
813
+ if (currentOid === undefined)
814
+ break;
815
+ const renamed = renameSymbol(resolve(currentOid), b.symbolName, b.newName);
816
+ const synthOid = `blob_${sha256hex(renamed).slice(0, 32)}`;
817
+ synthBlobs.set(synthOid, renamed);
818
+ tree.set(b.path, synthOid);
819
+ break;
820
+ }
821
+ case "move_symbol": {
822
+ if (!b.fromPath || !b.path || !b.symbolName)
823
+ break;
824
+ const fromOid = tree.get(b.fromPath);
825
+ if (fromOid === undefined)
826
+ break;
827
+ const extracted = extractSymbol(resolve(fromOid), b.symbolName);
828
+ if (!extracted)
829
+ break;
830
+ const toContent = tree.get(b.path) !== undefined ? resolve(tree.get(b.path)) : "";
831
+ const newTo = spliceSymbol(toContent, b.symbolName, extracted.text);
832
+ const fromSynth = `blob_${sha256hex(extracted.rest).slice(0, 32)}`;
833
+ const toSynth = `blob_${sha256hex(newTo).slice(0, 32)}`;
834
+ synthBlobs.set(fromSynth, extracted.rest);
835
+ synthBlobs.set(toSynth, newTo);
836
+ tree.set(b.fromPath, fromSynth);
837
+ tree.set(b.path, toSynth);
838
+ break;
839
+ }
840
+ case "note":
841
+ break;
842
+ }
843
+ }
844
+ function cmp(a, b) {
845
+ const x = a ?? "";
846
+ const y = b ?? "";
847
+ return x < y ? -1 : x > y ? 1 : 0;
848
+ }
849
+ //# sourceMappingURL=reducer.js.map