@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.
- package/LICENSE +201 -0
- package/README.md +221 -0
- package/dist/api/repo.d.ts +616 -0
- package/dist/api/repo.d.ts.map +1 -0
- package/dist/api/repo.js +1772 -0
- package/dist/api/repo.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +660 -0
- package/dist/cli.js.map +1 -0
- package/dist/concurrency/lease.d.ts +27 -0
- package/dist/concurrency/lease.d.ts.map +1 -0
- package/dist/concurrency/lease.js +63 -0
- package/dist/concurrency/lease.js.map +1 -0
- package/dist/core/canonical.d.ts +16 -0
- package/dist/core/canonical.d.ts.map +1 -0
- package/dist/core/canonical.js +66 -0
- package/dist/core/canonical.js.map +1 -0
- package/dist/core/cbor.d.ts +9 -0
- package/dist/core/cbor.d.ts.map +1 -0
- package/dist/core/cbor.js +213 -0
- package/dist/core/cbor.js.map +1 -0
- package/dist/core/clock.d.ts +18 -0
- package/dist/core/clock.d.ts.map +1 -0
- package/dist/core/clock.js +32 -0
- package/dist/core/clock.js.map +1 -0
- package/dist/core/identity.d.ts +34 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/identity.js +62 -0
- package/dist/core/identity.js.map +1 -0
- package/dist/hub/hubClient.d.ts +38 -0
- package/dist/hub/hubClient.d.ts.map +1 -0
- package/dist/hub/hubClient.js +157 -0
- package/dist/hub/hubClient.js.map +1 -0
- package/dist/hub/hubServer.d.ts +28 -0
- package/dist/hub/hubServer.d.ts.map +1 -0
- package/dist/hub/hubServer.js +365 -0
- package/dist/hub/hubServer.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/objects/types.d.ts +352 -0
- package/dist/objects/types.d.ts.map +1 -0
- package/dist/objects/types.js +17 -0
- package/dist/objects/types.js.map +1 -0
- package/dist/observe/logger.d.ts +27 -0
- package/dist/observe/logger.d.ts.map +1 -0
- package/dist/observe/logger.js +37 -0
- package/dist/observe/logger.js.map +1 -0
- package/dist/observe/metrics.d.ts +20 -0
- package/dist/observe/metrics.d.ts.map +1 -0
- package/dist/observe/metrics.js +42 -0
- package/dist/observe/metrics.js.map +1 -0
- package/dist/policy/owners.d.ts +5 -0
- package/dist/policy/owners.d.ts.map +1 -0
- package/dist/policy/owners.js +38 -0
- package/dist/policy/owners.js.map +1 -0
- package/dist/policy/reliability.d.ts +7 -0
- package/dist/policy/reliability.d.ts.map +1 -0
- package/dist/policy/reliability.js +41 -0
- package/dist/policy/reliability.js.map +1 -0
- package/dist/query/diff.d.ts +16 -0
- package/dist/query/diff.d.ts.map +1 -0
- package/dist/query/diff.js +32 -0
- package/dist/query/diff.js.map +1 -0
- package/dist/reducer/incremental.d.ts +2 -0
- package/dist/reducer/incremental.d.ts.map +1 -0
- package/dist/reducer/incremental.js +14 -0
- package/dist/reducer/incremental.js.map +1 -0
- package/dist/reducer/policy.d.ts +17 -0
- package/dist/reducer/policy.d.ts.map +1 -0
- package/dist/reducer/policy.js +143 -0
- package/dist/reducer/policy.js.map +1 -0
- package/dist/reducer/reducer.d.ts +155 -0
- package/dist/reducer/reducer.d.ts.map +1 -0
- package/dist/reducer/reducer.js +849 -0
- package/dist/reducer/reducer.js.map +1 -0
- package/dist/release/sbom.d.ts +7 -0
- package/dist/release/sbom.d.ts.map +1 -0
- package/dist/release/sbom.js +26 -0
- package/dist/release/sbom.js.map +1 -0
- package/dist/semantic/contract.d.ts +32 -0
- package/dist/semantic/contract.d.ts.map +1 -0
- package/dist/semantic/contract.js +150 -0
- package/dist/semantic/contract.js.map +1 -0
- package/dist/semantic/symbols.d.ts +45 -0
- package/dist/semantic/symbols.d.ts.map +1 -0
- package/dist/semantic/symbols.js +323 -0
- package/dist/semantic/symbols.js.map +1 -0
- package/dist/store/applyRedactions.d.ts +16 -0
- package/dist/store/applyRedactions.d.ts.map +1 -0
- package/dist/store/applyRedactions.js +60 -0
- package/dist/store/applyRedactions.js.map +1 -0
- package/dist/store/lock.d.ts +12 -0
- package/dist/store/lock.d.ts.map +1 -0
- package/dist/store/lock.js +63 -0
- package/dist/store/lock.js.map +1 -0
- package/dist/store/objectStore.d.ts +127 -0
- package/dist/store/objectStore.d.ts.map +1 -0
- package/dist/store/objectStore.js +549 -0
- package/dist/store/objectStore.js.map +1 -0
- package/dist/validation/repair.d.ts +31 -0
- package/dist/validation/repair.d.ts.map +1 -0
- package/dist/validation/repair.js +37 -0
- package/dist/validation/repair.js.map +1 -0
- package/dist/validation/runner.d.ts +20 -0
- package/dist/validation/runner.d.ts.map +1 -0
- package/dist/validation/runner.js +90 -0
- package/dist/validation/runner.js.map +1 -0
- 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
|