@izagood/avcs 0.31.2 → 0.32.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/dist/api/repo.d.ts.map +1 -1
- package/dist/api/repo.js +29 -6
- package/dist/api/repo.js.map +1 -1
- package/dist/cli.js +53 -14
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/merge/merge3.d.ts +16 -1
- package/dist/merge/merge3.d.ts.map +1 -1
- package/dist/merge/merge3.js +24 -6
- package/dist/merge/merge3.js.map +1 -1
- package/dist/reducer/policy.d.ts +1 -1
- package/dist/reducer/reducer.d.ts +61 -0
- package/dist/reducer/reducer.d.ts.map +1 -1
- package/dist/reducer/reducer.js +225 -11
- package/dist/reducer/reducer.js.map +1 -1
- package/package.json +1 -1
|
@@ -29,6 +29,22 @@ export interface AutoDecision {
|
|
|
29
29
|
rejectedOps: string[];
|
|
30
30
|
reason: string;
|
|
31
31
|
policyVersion: string;
|
|
32
|
+
/** Set when the decision settled one contended REGION of a file rather than a whole op
|
|
33
|
+
* (docs/22 §3.3): the base line range `[baseStart, baseEnd)` the winner took. Together
|
|
34
|
+
* with `key` and `chosenOp` this is the decision's idempotent identity — the same region
|
|
35
|
+
* never mints a second record on a re-reduce. */
|
|
36
|
+
region?: {
|
|
37
|
+
baseStart: number;
|
|
38
|
+
baseEnd: number;
|
|
39
|
+
};
|
|
40
|
+
/** Per-option score breakdown, in option order: which option each op represented, the
|
|
41
|
+
* score that represented it, and whether it was out of candidacy. Answers "why did my
|
|
42
|
+
* change lose this region". Free — `evaluateOp` already computed all of it. */
|
|
43
|
+
optionScores?: {
|
|
44
|
+
opOid: string;
|
|
45
|
+
score: number;
|
|
46
|
+
excluded: boolean;
|
|
47
|
+
}[];
|
|
32
48
|
}
|
|
33
49
|
export interface ReductionResult {
|
|
34
50
|
/** path → blobOid */
|
|
@@ -89,6 +105,10 @@ export interface FileConflict {
|
|
|
89
105
|
file: string;
|
|
90
106
|
ops: string[];
|
|
91
107
|
regions: ConflictRegion[];
|
|
108
|
+
/** Binary/non-line-mergeable content: the whole file is ONE opaque contest and the
|
|
109
|
+
* region's `sides` index distinct contents, not `ops`. Never arbitrated (docs/22 §3.1)
|
|
110
|
+
* — side → op is not recoverable, so only a human can settle it. */
|
|
111
|
+
atomic?: boolean;
|
|
92
112
|
}
|
|
93
113
|
/**
|
|
94
114
|
* Detect line-level merge conflicts among CONCURRENT accepted edit_file ops on the same
|
|
@@ -107,6 +127,29 @@ export interface FileConflict {
|
|
|
107
127
|
* resolution is the identity and the buckets are exactly the declared paths, as before.
|
|
108
128
|
*/
|
|
109
129
|
export declare function detectFileConflicts(ops: Operation[], result: ReductionResult, blobContent: Map<string, Buffer>): FileConflict[];
|
|
130
|
+
/** The authoritative conflict set after region arbitration (docs/22 §3.4). */
|
|
131
|
+
export interface FileConflictArbitration {
|
|
132
|
+
/** Files that still hold at least one region policy could not decide. A file whose every
|
|
133
|
+
* region was decided drops out entirely — there is nothing left to ask. */
|
|
134
|
+
remaining: FileConflict[];
|
|
135
|
+
/** One {@link AutoDecision} per decided region (docs/22 §3.3). */
|
|
136
|
+
decisions: AutoDecision[];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Arbitrate the regions {@link detectFileConflicts} found — the authoritative pass, and the
|
|
140
|
+
* one place where side → op is exact: `FileConflict.ops` is the file's concurrent frontier in
|
|
141
|
+
* canonical order and `region.options[].sides` index straight into it.
|
|
142
|
+
*
|
|
143
|
+
* A decided region leaves the conflict set (policy decided it, so it is no longer a question
|
|
144
|
+
* for a human) and is recorded as an `AutoDecision` — an automatic decision without an audit
|
|
145
|
+
* trail is not allowed (docs/00 principle 4, at region granularity). An undecided region
|
|
146
|
+
* stays exactly as it was: the tree holds the deterministic fallback content and the conflict
|
|
147
|
+
* still reaches the release gate.
|
|
148
|
+
*
|
|
149
|
+
* Scores are lazy and memoized per op, so only the ops of a file that actually contended are
|
|
150
|
+
* ever evaluated (docs/22 R-c).
|
|
151
|
+
*/
|
|
152
|
+
export declare function arbitrateFileConflicts(fileConflicts: FileConflict[], input: ReduceInput): FileConflictArbitration;
|
|
110
153
|
/** A single group's locally-decided statuses + the conflicts/autoDecisions it emitted. */
|
|
111
154
|
export interface PerKeyDecision {
|
|
112
155
|
local: Map<string, OperationStatus>;
|
|
@@ -164,4 +207,22 @@ export declare class NonIncrementalError extends Error {
|
|
|
164
207
|
export declare function reduceIncremental(snap: ReduceSnapshot, next: ReduceInput): ReduceSnapshot;
|
|
165
208
|
export declare function serializeSnapshot(snap: ReduceSnapshot): unknown;
|
|
166
209
|
export declare function deserializeSnapshot(raw: unknown): ReduceSnapshot;
|
|
210
|
+
/** One op's policy verdict, as region arbitration needs it (docs/22 §3.2). */
|
|
211
|
+
export interface OpScore {
|
|
212
|
+
score: number;
|
|
213
|
+
/** Out of candidacy: a failed evidence gate, or a rule that reserves the call for a
|
|
214
|
+
* human. An excluded op's content must never take a region — that exclusion is the
|
|
215
|
+
* real effect of this whole track (docs/22 §3.2-3). */
|
|
216
|
+
excluded: boolean;
|
|
217
|
+
}
|
|
218
|
+
/** opOid → verdict. `undefined` for an op the scorer does not know, which makes
|
|
219
|
+
* arbitration abstain rather than guess. */
|
|
220
|
+
export type OpScorer = (opOid: string) => OpScore | undefined;
|
|
221
|
+
/**
|
|
222
|
+
* Build an {@link OpScorer} from a bare `ReduceInput`, for a caller outside `reduce` — the
|
|
223
|
+
* authoritative post-reduce pass (`detectFileConflicts` → {@link arbitrateFileConflicts}).
|
|
224
|
+
* Evaluation is lazy and memoized per op, so only the ops of a file that actually contended
|
|
225
|
+
* are ever scored.
|
|
226
|
+
*/
|
|
227
|
+
export declare function buildOpScorer(input: ReduceInput): OpScorer;
|
|
167
228
|
//# sourceMappingURL=reducer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reducer.d.ts","sourceRoot":"","sources":["../../src/reducer/reducer.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,SAAS,EACT,eAAe,EACf,MAAM,EACP,MAAM,qBAAqB,CAAC;AAG7B,OAAO,
|
|
1
|
+
{"version":3,"file":"reducer.d.ts","sourceRoot":"","sources":["../../src/reducer/reducer.ts"],"names":[],"mappings":"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"}
|
package/dist/reducer/reducer.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { sha256hex, canonicalize } from "../core/canonical.js";
|
|
13
13
|
import { evaluateOp } from "./policy.js";
|
|
14
14
|
import { buildAliasMap, resolvePath } from "./aliases.js";
|
|
15
|
-
import { merge3 } from "../merge/merge3.js";
|
|
15
|
+
import { merge3, diffHunks } from "../merge/merge3.js";
|
|
16
16
|
import { ownersFor } from "../policy/owners.js";
|
|
17
17
|
import { isBinary } from "../core/bytes.js";
|
|
18
18
|
import { Buffer } from "node:buffer";
|
|
@@ -179,7 +179,7 @@ export function detectFileConflicts(ops, result, blobContent) {
|
|
|
179
179
|
options: distinct.map((text, i) => ({ sides: [i], text })),
|
|
180
180
|
},
|
|
181
181
|
];
|
|
182
|
-
out.push({ file, ops: ordered.map((o) => o.oid), regions });
|
|
182
|
+
out.push({ file, ops: ordered.map((o) => o.oid), regions, atomic: true });
|
|
183
183
|
continue;
|
|
184
184
|
}
|
|
185
185
|
const base = baseBuf.toString("utf8");
|
|
@@ -190,6 +190,64 @@ export function detectFileConflicts(ops, result, blobContent) {
|
|
|
190
190
|
}
|
|
191
191
|
return out;
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Arbitrate the regions {@link detectFileConflicts} found — the authoritative pass, and the
|
|
195
|
+
* one place where side → op is exact: `FileConflict.ops` is the file's concurrent frontier in
|
|
196
|
+
* canonical order and `region.options[].sides` index straight into it.
|
|
197
|
+
*
|
|
198
|
+
* A decided region leaves the conflict set (policy decided it, so it is no longer a question
|
|
199
|
+
* for a human) and is recorded as an `AutoDecision` — an automatic decision without an audit
|
|
200
|
+
* trail is not allowed (docs/00 principle 4, at region granularity). An undecided region
|
|
201
|
+
* stays exactly as it was: the tree holds the deterministic fallback content and the conflict
|
|
202
|
+
* still reaches the release gate.
|
|
203
|
+
*
|
|
204
|
+
* Scores are lazy and memoized per op, so only the ops of a file that actually contended are
|
|
205
|
+
* ever evaluated (docs/22 R-c).
|
|
206
|
+
*/
|
|
207
|
+
export function arbitrateFileConflicts(fileConflicts, input) {
|
|
208
|
+
if (fileConflicts.length === 0)
|
|
209
|
+
return { remaining: [], decisions: [] };
|
|
210
|
+
const scoreOf = buildOpScorer(input);
|
|
211
|
+
const remaining = [];
|
|
212
|
+
const decisions = [];
|
|
213
|
+
const seen = new Set(); // idempotent identity: key + region bounds + winner (R-d)
|
|
214
|
+
for (const fc of fileConflicts) {
|
|
215
|
+
if (fc.atomic) {
|
|
216
|
+
remaining.push(fc);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const key = `file:${fc.file}`;
|
|
220
|
+
const open = [];
|
|
221
|
+
for (const region of fc.regions) {
|
|
222
|
+
const sideOps = (side) => {
|
|
223
|
+
const oid = fc.ops[side];
|
|
224
|
+
return oid === undefined ? [] : [oid];
|
|
225
|
+
};
|
|
226
|
+
const v = judgeRegion(region, sideOps, scoreOf);
|
|
227
|
+
if (!v) {
|
|
228
|
+
open.push(region);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const idem = `${key}|${region.baseStart}|${region.baseEnd}|${v.chosen}`;
|
|
232
|
+
if (seen.has(idem))
|
|
233
|
+
continue;
|
|
234
|
+
seen.add(idem);
|
|
235
|
+
decisions.push({
|
|
236
|
+
key,
|
|
237
|
+
conflictId: conflictIdFor(key),
|
|
238
|
+
chosenOp: v.chosen,
|
|
239
|
+
rejectedOps: v.rejected,
|
|
240
|
+
reason: "region-arbitration",
|
|
241
|
+
policyVersion: input.policy.version,
|
|
242
|
+
region: { baseStart: region.baseStart, baseEnd: region.baseEnd },
|
|
243
|
+
optionScores: v.optionScores,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (open.length)
|
|
247
|
+
remaining.push({ ...fc, regions: open });
|
|
248
|
+
}
|
|
249
|
+
return { remaining, decisions };
|
|
250
|
+
}
|
|
193
251
|
export function reduce(input) {
|
|
194
252
|
return snapshotReduce(input).result;
|
|
195
253
|
}
|
|
@@ -275,14 +333,15 @@ function frontier(ops, statuses, anc) {
|
|
|
275
333
|
covered.add(a);
|
|
276
334
|
return [...acceptedIds].filter((id) => !covered.has(id));
|
|
277
335
|
}
|
|
278
|
-
function materializeProjection(ops, statuses, anc, materializeStatuses, blobContent) {
|
|
336
|
+
function materializeProjection(ops, statuses, anc, materializeStatuses, blobContent, scoreOf) {
|
|
279
337
|
const projected = ops.filter((o) => materializeStatuses.has(statuses.get(o.oid)));
|
|
280
338
|
const ordered = kahnOrder(projected, anc);
|
|
281
339
|
const alias = aliasCtxFor(projected, anc);
|
|
282
340
|
const tree = new Map();
|
|
283
341
|
const synthBlobs = new Map();
|
|
342
|
+
const arb = scoreOf ? { scoreOf, prov: new Map() } : undefined;
|
|
284
343
|
for (const op of ordered)
|
|
285
|
-
applyOp(tree, op, blobContent, synthBlobs, alias);
|
|
344
|
+
applyOp(tree, op, blobContent, synthBlobs, alias, arb);
|
|
286
345
|
return { tree, treeHash: treeHashOf(tree), headOps: frontier(ops, statuses, anc), synthBlobs: pruneSynth(tree, synthBlobs) };
|
|
287
346
|
}
|
|
288
347
|
/**
|
|
@@ -295,7 +354,7 @@ function materializeProjection(ops, statuses, anc, materializeStatuses, blobCont
|
|
|
295
354
|
* Replayed ops only ever read/write dirty paths, so a fresh replay tree + clean base
|
|
296
355
|
* entries reconstructs the full tree exactly. Equivalence is enforced by the A0 harness.
|
|
297
356
|
*/
|
|
298
|
-
function materializeIncremental(ops, statuses, anc, materializeStatuses, blobContent, base, dirtyPaths) {
|
|
357
|
+
function materializeIncremental(ops, statuses, anc, materializeStatuses, blobContent, base, dirtyPaths, scoreOf) {
|
|
299
358
|
const projected = ops.filter((o) => materializeStatuses.has(statuses.get(o.oid)));
|
|
300
359
|
const ordered = kahnOrder(projected, anc);
|
|
301
360
|
// The alias map is a function of the WHOLE projected rename set, never of the dirty
|
|
@@ -309,9 +368,10 @@ function materializeIncremental(ops, statuses, anc, materializeStatuses, blobCon
|
|
|
309
368
|
// dropped as dirty. `AVCS_VERIFY_INCREMENTAL=1` and the C22 cases gate it.
|
|
310
369
|
const replayTree = new Map();
|
|
311
370
|
const replaySynth = new Map();
|
|
371
|
+
const arb = scoreOf ? { scoreOf, prov: new Map() } : undefined;
|
|
312
372
|
for (const op of ordered) {
|
|
313
373
|
if (pathsOf(op).some((p) => dirtyPaths.has(p)))
|
|
314
|
-
applyOp(replayTree, op, blobContent, replaySynth, alias);
|
|
374
|
+
applyOp(replayTree, op, blobContent, replaySynth, alias, arb);
|
|
315
375
|
}
|
|
316
376
|
// Final tree = clean base paths (not dirty) + replayed dirty paths.
|
|
317
377
|
const tree = new Map();
|
|
@@ -380,7 +440,7 @@ export function snapshotReduce(input) {
|
|
|
380
440
|
for (const o of ops)
|
|
381
441
|
if (keysOf(o).length === 0 && statuses.get(o.oid) === "proposed")
|
|
382
442
|
statuses.set(o.oid, "accepted");
|
|
383
|
-
const { tree, treeHash, headOps, synthBlobs } = materializeProjection(ops, statuses, anc, materializeStatuses, input.blobContent ?? new Map());
|
|
443
|
+
const { tree, treeHash, headOps, synthBlobs } = materializeProjection(ops, statuses, anc, materializeStatuses, input.blobContent ?? new Map(), scorerFrom(ops, evalOf));
|
|
384
444
|
const result = { tree, treeHash, statuses, conflicts, autoDecisions, fileConflicts: [], headOps, synthBlobs, blockedReasons, untrustedEvidence: 0 };
|
|
385
445
|
const stats = { groupsTotal: groups.size, groupsRecomputed: groups.size, groupsReused: 0, dirtyKeys: groups.size };
|
|
386
446
|
return { input, result, perKey, groupOrder, groupMembers, stats };
|
|
@@ -597,6 +657,18 @@ export function reduceIncremental(snap, next) {
|
|
|
597
657
|
return true; // a delta op became this op's ancestor
|
|
598
658
|
return false;
|
|
599
659
|
};
|
|
660
|
+
// A contended region's winner is a function of the SCORES of the ops writing that path
|
|
661
|
+
// (docs/22 §4.2), so a change to a score INPUT dirties the path even when the projection is
|
|
662
|
+
// untouched: new evidence for an op, or a reliability shift for its actor, can flip which
|
|
663
|
+
// option takes the region. Without this the warm path would keep a region the cold path
|
|
664
|
+
// re-decides, and warm/cold would disagree on the tree — the one thing incremental reduce
|
|
665
|
+
// may never do. Group dirtiness already covers both signals; this carries them to PATHS.
|
|
666
|
+
const newEvidenceFor = new Set();
|
|
667
|
+
for (const e of evidence)
|
|
668
|
+
if (!prevEvIds.has(e.oid))
|
|
669
|
+
for (const oid of e.forOps)
|
|
670
|
+
newEvidenceFor.add(oid);
|
|
671
|
+
const scoreChanged = (o) => newEvidenceFor.has(o.oid) || changedActors.has(o.actor.id);
|
|
600
672
|
const dirtyPaths = new Set();
|
|
601
673
|
for (const o of ops) {
|
|
602
674
|
const oid = o.oid;
|
|
@@ -619,8 +691,12 @@ export function reduceIncremental(snap, next) {
|
|
|
619
691
|
else if (projectedNow(oid) && isCrossPath(o))
|
|
620
692
|
for (const p of pathsOf(o))
|
|
621
693
|
dirtyPaths.add(p);
|
|
694
|
+
// Score-input change (docs/22): the op still projects, but what it is WORTH changed.
|
|
695
|
+
else if (projectedNow(oid) && scoreChanged(o))
|
|
696
|
+
for (const p of pathsOf(o))
|
|
697
|
+
dirtyPaths.add(p);
|
|
622
698
|
}
|
|
623
|
-
const { tree, treeHash, headOps, synthBlobs } = materializeIncremental(ops, statuses, anc, materializeStatuses, next.blobContent ?? new Map(), snap.result, dirtyPaths);
|
|
699
|
+
const { tree, treeHash, headOps, synthBlobs } = materializeIncremental(ops, statuses, anc, materializeStatuses, next.blobContent ?? new Map(), snap.result, dirtyPaths, scorerFrom(ops, evalOf));
|
|
624
700
|
const result = { tree, treeHash, statuses, conflicts, autoDecisions, fileConflicts: [], headOps, synthBlobs, blockedReasons, untrustedEvidence: 0 };
|
|
625
701
|
const stats = { groupsTotal: groups.size, groupsRecomputed: recomputed, groupsReused: reused, dirtyKeys: dirty.size };
|
|
626
702
|
return { input: next, result, perKey, groupOrder, groupMembers, stats };
|
|
@@ -918,6 +994,125 @@ function kahnOrder(ops, anc) {
|
|
|
918
994
|
}
|
|
919
995
|
return order;
|
|
920
996
|
}
|
|
997
|
+
/**
|
|
998
|
+
* The arbitration rule (docs/22 §3.2) — the one place it is written down.
|
|
999
|
+
*
|
|
1000
|
+
* `opsForSide` maps a variant index back to the op(s) that produced it. The caller owns
|
|
1001
|
+
* that mapping because it differs between the pairwise merge inside `applyOp` and the
|
|
1002
|
+
* authoritative N-way merge in `detectFileConflicts`.
|
|
1003
|
+
*
|
|
1004
|
+
* - An option several ops agree on is represented by its HIGHEST-scoring op. An average
|
|
1005
|
+
* would let a low-trust actor dilute an option merely by co-signing it.
|
|
1006
|
+
* - Any excluded op excludes its whole option: conservative on blocking, generous on
|
|
1007
|
+
* score. An option that cannot be auto-accepted cannot win a region either.
|
|
1008
|
+
* - A tie is never broken by recency. It returns `null`, the region stays a conflict and
|
|
1009
|
+
* a human decides. docs/00 principle 6 allows recency as a last tie-break for op
|
|
1010
|
+
* PROMOTION; region content is where meaning diverges, so deciding it quietly is worse
|
|
1011
|
+
* than raising it.
|
|
1012
|
+
*/
|
|
1013
|
+
function judgeRegion(region, opsForSide, scoreOf) {
|
|
1014
|
+
const cands = [];
|
|
1015
|
+
for (const [i, opt] of region.options.entries()) {
|
|
1016
|
+
const oids = [...new Set(opt.sides.flatMap(opsForSide))];
|
|
1017
|
+
if (oids.length === 0)
|
|
1018
|
+
return null; // no op behind an option ⇒ nothing to weigh
|
|
1019
|
+
let excluded = false;
|
|
1020
|
+
let rep;
|
|
1021
|
+
for (const oid of oids) {
|
|
1022
|
+
const v = scoreOf(oid);
|
|
1023
|
+
if (!v)
|
|
1024
|
+
return null; // an op the policy cannot speak about ⇒ abstain, never guess
|
|
1025
|
+
if (v.excluded)
|
|
1026
|
+
excluded = true;
|
|
1027
|
+
if (!rep || v.score > rep.score || (v.score === rep.score && oid < rep.opOid))
|
|
1028
|
+
rep = { opOid: oid, score: v.score };
|
|
1029
|
+
}
|
|
1030
|
+
cands.push({ i, excluded, opOid: rep.opOid, score: rep.score });
|
|
1031
|
+
}
|
|
1032
|
+
const live = cands.filter((c) => !c.excluded);
|
|
1033
|
+
if (live.length === 0)
|
|
1034
|
+
return null; // every option blocked ⇒ a human, and no unverified
|
|
1035
|
+
const topScore = Math.max(...live.map((c) => c.score)); // content takes the region
|
|
1036
|
+
const top = live.filter((c) => c.score === topScore);
|
|
1037
|
+
if (top.length !== 1)
|
|
1038
|
+
return null; // tie ⇒ a human
|
|
1039
|
+
const win = top[0];
|
|
1040
|
+
return {
|
|
1041
|
+
option: win.i,
|
|
1042
|
+
chosen: win.opOid,
|
|
1043
|
+
rejected: cands.filter((c) => c.i !== win.i).map((c) => c.opOid),
|
|
1044
|
+
optionScores: cands.map((c) => ({ opOid: c.opOid, score: c.score, excluded: c.excluded })),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* side → op for `applyOp`'s PAIRWISE merge, which is not the clean mapping docs/22 §3.2
|
|
1049
|
+
* assumed. Side 1 is the op being applied, but side 0 is the ACCUMULATED tree content —
|
|
1050
|
+
* the composition of every content op already applied at that path.
|
|
1051
|
+
*
|
|
1052
|
+
* So the incumbent side is attributed per REGION: only the contributors whose own change
|
|
1053
|
+
* (measured against this merge's base, so the coordinates match the region's) touches the
|
|
1054
|
+
* contested span may speak for it, and among those the strongest represents the option —
|
|
1055
|
+
* the same rule agreement uses. Without the span filter, a high-trust edit to an unrelated
|
|
1056
|
+
* part of the file would defend a low-trust edit's region, and the tree would disagree with
|
|
1057
|
+
* the authoritative N-way pass, which sees every op separately.
|
|
1058
|
+
*
|
|
1059
|
+
* The per-contributor diff is lazy and memoized per merge, so a file with no contended
|
|
1060
|
+
* region pays nothing at all and a contended one pays at most one line-diff per contributor
|
|
1061
|
+
* that a region actually asks about (docs/22 R-c). The trail only ever holds the ops still
|
|
1062
|
+
* projected at that path — a sequential edit chain is superseded before it gets here — so it
|
|
1063
|
+
* is the concurrent frontier's size, not the file's history, that bounds this.
|
|
1064
|
+
*/
|
|
1065
|
+
function pairwiseArbiter(baseText, incumbents, self, contentOf, scoreOf) {
|
|
1066
|
+
const baseLines = baseText.split("\n");
|
|
1067
|
+
const spans = new Map();
|
|
1068
|
+
const spansOf = (op) => {
|
|
1069
|
+
const oid = op.oid;
|
|
1070
|
+
let s = spans.get(oid);
|
|
1071
|
+
if (!s) {
|
|
1072
|
+
s = diffHunks(baseLines, contentOf(op).split("\n"));
|
|
1073
|
+
spans.set(oid, s);
|
|
1074
|
+
}
|
|
1075
|
+
return s;
|
|
1076
|
+
};
|
|
1077
|
+
const touches = (op, start, end) => spansOf(op).some((h) => (h.start === h.end ? h.start >= start && h.start <= end : h.start < end && h.end > start));
|
|
1078
|
+
const selfOid = self.oid;
|
|
1079
|
+
return (region) => {
|
|
1080
|
+
const opsForSide = (side) => {
|
|
1081
|
+
if (side !== 0)
|
|
1082
|
+
return [selfOid];
|
|
1083
|
+
const hit = incumbents.filter((o) => touches(o, region.baseStart, region.baseEnd));
|
|
1084
|
+
// No contributor claims the span (a composition whose hunks shifted): fall back to
|
|
1085
|
+
// the whole trail rather than inventing an attribution.
|
|
1086
|
+
return (hit.length ? hit : incumbents).map((o) => o.oid);
|
|
1087
|
+
};
|
|
1088
|
+
return judgeRegion(region, opsForSide, scoreOf)?.option ?? null;
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
/** Wrap a reduce's memoized `evalOf` as an {@link OpScorer}. Arbitration REUSES the scores
|
|
1092
|
+
* the group decision already computed — never a recomputation per region (docs/22 R-c). A
|
|
1093
|
+
* contended region IS a conflict, so ops are evaluated with `inConflict = true`, the same
|
|
1094
|
+
* flag their contended group used. */
|
|
1095
|
+
function scorerFrom(ops, evalOf) {
|
|
1096
|
+
const byId = new Map(ops.map((o) => [o.oid, o]));
|
|
1097
|
+
return (oid) => {
|
|
1098
|
+
const op = byId.get(oid);
|
|
1099
|
+
if (!op)
|
|
1100
|
+
return undefined;
|
|
1101
|
+
const ev = evalOf(op, true);
|
|
1102
|
+
return { score: ev.score, excluded: ev.blocked || ev.requiresHuman };
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Build an {@link OpScorer} from a bare `ReduceInput`, for a caller outside `reduce` — the
|
|
1107
|
+
* authoritative post-reduce pass (`detectFileConflicts` → {@link arbitrateFileConflicts}).
|
|
1108
|
+
* Evaluation is lazy and memoized per op, so only the ops of a file that actually contended
|
|
1109
|
+
* are ever scored.
|
|
1110
|
+
*/
|
|
1111
|
+
export function buildOpScorer(input) {
|
|
1112
|
+
const evidence = [...input.evidence].sort((a, b) => cmp(a.createdAt, b.createdAt) || cmp(a.oid, b.oid));
|
|
1113
|
+
const evalOf = makeEvalOf(input.policy, input.intents, buildEvByOp(evidence), input.reliability ?? new Map());
|
|
1114
|
+
return scorerFrom(input.ops, evalOf);
|
|
1115
|
+
}
|
|
921
1116
|
/**
|
|
922
1117
|
* Apply one projected op to the tree.
|
|
923
1118
|
*
|
|
@@ -927,14 +1122,16 @@ function kahnOrder(ops, anc) {
|
|
|
927
1122
|
* order put first. With no renames in the op set the alias map is empty, resolution is
|
|
928
1123
|
* the identity, and this function behaves exactly as it did before.
|
|
929
1124
|
*/
|
|
930
|
-
function applyOp(tree, op, blobContent, synthBlobs, alias) {
|
|
1125
|
+
function applyOp(tree, op, blobContent, synthBlobs, alias, arb) {
|
|
931
1126
|
const b = op.body;
|
|
932
1127
|
const resolve = (oid) => synthBlobs.get(oid) ?? blobContent.get(oid) ?? Buffer.alloc(0);
|
|
933
1128
|
const at = (declared) => resolvedPath(op, declared, alias);
|
|
934
1129
|
switch (b.kind) {
|
|
935
1130
|
case "put_file":
|
|
936
|
-
if (b.path && b.blobOid)
|
|
1131
|
+
if (b.path && b.blobOid) {
|
|
937
1132
|
tree.set(at(b.path), b.blobOid);
|
|
1133
|
+
arb?.prov.set(at(b.path), [op]); // a whole-content write restarts the trail
|
|
1134
|
+
}
|
|
938
1135
|
break;
|
|
939
1136
|
case "edit_file": {
|
|
940
1137
|
if (!b.path || !b.blobOid)
|
|
@@ -953,21 +1150,32 @@ function applyOp(tree, op, blobContent, synthBlobs, alias) {
|
|
|
953
1150
|
const synthOid = `blob_${sha256hex(opNew).slice(0, 32)}`;
|
|
954
1151
|
synthBlobs.set(synthOid, opNew);
|
|
955
1152
|
tree.set(path, synthOid);
|
|
1153
|
+
arb?.prov.set(path, [op]);
|
|
956
1154
|
break;
|
|
957
1155
|
}
|
|
958
1156
|
// Apply this op's patch (opBase→opNew) onto the accumulated content. Disjoint
|
|
959
1157
|
// line changes compose (order-independent); an overlap with a prior concurrent op
|
|
960
1158
|
// keeps `current` (deterministic incumbent) — the overlap is reported separately
|
|
961
1159
|
// by detectFileConflicts over the full op set. Language-neutral: pure text.
|
|
962
|
-
|
|
1160
|
+
// An overlap is no longer settled by which op got here first: the injected arbiter
|
|
1161
|
+
// asks the policy which option wins the region (docs/22 §3.2). It abstains — leaving
|
|
1162
|
+
// the incumbent and the conflict — whenever policy cannot separate the options.
|
|
1163
|
+
const incumbents = arb?.prov.get(path) ?? [];
|
|
1164
|
+
const arbitrate = arb && incumbents.length
|
|
1165
|
+
? pairwiseArbiter(opBase.toString("utf8"), incumbents, op, (o) => resolve(o.body.blobOid ?? "").toString("utf8"), arb.scoreOf)
|
|
1166
|
+
: undefined;
|
|
1167
|
+
const m = merge3(opBase.toString("utf8"), [current.toString("utf8"), opNew.toString("utf8")], { onConflict: "first", arbitrate });
|
|
963
1168
|
const mergedBuf = Buffer.from(m.merged, "utf8");
|
|
964
1169
|
const synthOid = `blob_${sha256hex(mergedBuf).slice(0, 32)}`;
|
|
965
1170
|
synthBlobs.set(synthOid, mergedBuf);
|
|
966
1171
|
tree.set(path, synthOid);
|
|
1172
|
+
if (arb)
|
|
1173
|
+
arb.prov.set(path, [...incumbents, op]);
|
|
967
1174
|
break;
|
|
968
1175
|
}
|
|
969
1176
|
case "delete_file":
|
|
970
1177
|
tree.delete(at(b.path ?? op.target.entityId));
|
|
1178
|
+
arb?.prov.delete(at(b.path ?? op.target.entityId));
|
|
971
1179
|
break;
|
|
972
1180
|
case "rename_file": {
|
|
973
1181
|
if (!b.fromPath || !b.path)
|
|
@@ -987,6 +1195,12 @@ function applyOp(tree, op, blobContent, synthBlobs, alias) {
|
|
|
987
1195
|
if (blob !== undefined) {
|
|
988
1196
|
tree.delete(b.fromPath);
|
|
989
1197
|
tree.set(b.path, blob);
|
|
1198
|
+
if (arb) {
|
|
1199
|
+
const trail = arb.prov.get(b.fromPath);
|
|
1200
|
+
arb.prov.delete(b.fromPath);
|
|
1201
|
+
if (trail)
|
|
1202
|
+
arb.prov.set(b.path, trail); // the content moved, and so does its trail
|
|
1203
|
+
}
|
|
990
1204
|
}
|
|
991
1205
|
break;
|
|
992
1206
|
}
|