@lmzhen/dsh-evolution-merge 0.30.16 → 0.30.18

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 (90) hide show
  1. package/README.md +4 -3
  2. package/lib/index.js +2325 -855
  3. package/lib/types/adapters/library.js +4 -1
  4. package/lib/types/adapters/model.d.ts +31 -0
  5. package/lib/types/adapters/model.js +46 -0
  6. package/lib/types/approve-command.d.ts +2 -0
  7. package/lib/types/approve-command.js +41 -46
  8. package/lib/types/authoring-flow.d.ts +3 -1
  9. package/lib/types/authoring-flow.js +15 -5
  10. package/lib/types/command-surface.d.ts +5 -0
  11. package/lib/types/command-surface.js +44 -0
  12. package/lib/types/commit-assembly.d.ts +2 -0
  13. package/lib/types/commit-assembly.js +7 -2
  14. package/lib/types/commit-execute.d.ts +7 -1
  15. package/lib/types/commit-execute.js +40 -10
  16. package/lib/types/commit-recovery.d.ts +7 -2
  17. package/lib/types/commit-recovery.js +73 -96
  18. package/lib/types/contracts.d.ts +1 -1
  19. package/lib/types/contracts.js +1 -1
  20. package/lib/types/coordinator.d.ts +3 -2
  21. package/lib/types/coordinator.js +4 -3
  22. package/lib/types/decision.js +1 -1
  23. package/lib/types/doctor.d.ts +7 -0
  24. package/lib/types/doctor.js +104 -4
  25. package/lib/types/domain/claim.d.ts +4 -1
  26. package/lib/types/domain/claim.js +2 -2
  27. package/lib/types/domain/commit-journal-store.js +5 -1
  28. package/lib/types/domain/commit-journal.js +1 -1
  29. package/lib/types/domain/consistency.d.ts +22 -0
  30. package/lib/types/domain/consistency.js +50 -0
  31. package/lib/types/domain/ledger.js +10 -1
  32. package/lib/types/domain/migrate-v3.d.ts +20 -0
  33. package/lib/types/domain/migrate-v3.js +68 -0
  34. package/lib/types/domain/row-tx.d.ts +27 -0
  35. package/lib/types/domain/row-tx.js +31 -0
  36. package/lib/types/domain/row-v3-bridge.d.ts +6 -0
  37. package/lib/types/domain/row-v3-bridge.js +47 -0
  38. package/lib/types/domain/row-v3.d.ts +113 -0
  39. package/lib/types/domain/row-v3.js +107 -0
  40. package/lib/types/domain/row.d.ts +1 -0
  41. package/lib/types/domain/staging-index.d.ts +14 -0
  42. package/lib/types/domain/staging-index.js +41 -0
  43. package/lib/types/domain/state-v3.d.ts +15 -0
  44. package/lib/types/domain/state-v3.js +80 -0
  45. package/lib/types/facts.d.ts +24 -0
  46. package/lib/types/facts.js +68 -0
  47. package/lib/types/gate-filter.d.ts +31 -0
  48. package/lib/types/gate-filter.js +64 -0
  49. package/lib/types/index.d.ts +5 -2
  50. package/lib/types/index.js +16 -26
  51. package/lib/types/ladder.js +1 -1
  52. package/lib/types/merge-routes.d.ts +2 -2
  53. package/lib/types/merge-routes.js +21 -22
  54. package/lib/types/metrics.d.ts +16 -0
  55. package/lib/types/metrics.js +27 -0
  56. package/lib/types/new-child-authoring.d.ts +3 -1
  57. package/lib/types/new-child-authoring.js +14 -3
  58. package/lib/types/pending-actions.d.ts +2 -2
  59. package/lib/types/pending-actions.js +17 -17
  60. package/lib/types/propose.d.ts +26 -0
  61. package/lib/types/propose.js +69 -0
  62. package/lib/types/read-commands.d.ts +2 -2
  63. package/lib/types/read-commands.js +5 -3
  64. package/lib/types/resume-command.d.ts +2 -0
  65. package/lib/types/resume-command.js +4 -1
  66. package/lib/types/round2-outcome.js +17 -9
  67. package/lib/types/runtime.d.ts +4 -1
  68. package/lib/types/runtime.js +99 -29
  69. package/lib/types/scan-ports.d.ts +3 -1
  70. package/lib/types/scan-ports.js +2 -0
  71. package/lib/types/scan-run.js +116 -51
  72. package/lib/types/screening-port.d.ts +47 -0
  73. package/lib/types/screening-port.js +93 -0
  74. package/lib/types/screening.d.ts +30 -0
  75. package/lib/types/screening.js +75 -0
  76. package/lib/types/state.d.ts +14 -0
  77. package/lib/types/state.js +72 -1
  78. package/lib/types/status.d.ts +3 -0
  79. package/lib/types/status.js +17 -0
  80. package/lib/types/target-claim.d.ts +1 -0
  81. package/lib/types/target-claim.js +63 -18
  82. package/lib/types/unit-claim.d.ts +5 -0
  83. package/lib/types/unit-claim.js +15 -0
  84. package/lib/types/wire-projection.d.ts +36 -0
  85. package/lib/types/wire-projection.js +69 -0
  86. package/lib/types/wire.d.ts +254 -0
  87. package/lib/types/wire.js +3 -0
  88. package/package.json +8 -4
  89. package/lib/types/routes.d.ts +0 -70
  90. package/lib/types/routes.js +0 -78
@@ -2,10 +2,11 @@
2
2
  * The scan pair loop. Registry/report lifecycle stays in withMergeRun; this module owns
3
3
  * candidate → round1 → pending/fuse → commit transitions and returns the report draft.
4
4
  */
5
- import { pairKey, } from "./contracts.js";
6
- import { transition } from "./coordinator.js";
7
- import { dropRow, loadMergeState, upsertRow } from "./state.js";
5
+ import { pairKey, rowIdOf, } from "./contracts.js";
6
+ import { dropRowV3, loadMergeState, transactRows } from "./state.js";
8
7
  import { recordPair } from "./domain/pair-index-store.js";
8
+ import { writeStagingIndex } from "./domain/staging-index.js";
9
+ import { v3ToLegacy } from "./domain/row-v3-bridge.js";
9
10
  const isAborted = (signal) => signal.aborted;
10
11
  function at(now) {
11
12
  return new Date(now()).toISOString();
@@ -37,6 +38,8 @@ export async function runScan(options) {
37
38
  const stage = options.stage ?? (async () => { });
38
39
  const candidates = await options.ports.screening(options.signal);
39
40
  const before = await loadMergeState(options.io, options.home);
41
+ if (!before.ok)
42
+ throw new Error(before.note ?? 'merge-state-invalid');
40
43
  const queueBefore = { verify: 0, pending: 0, fuse: 0, skipped: 0 };
41
44
  for (const row of before.state.rows)
42
45
  queueBefore[row.queue] += 1;
@@ -61,28 +64,37 @@ export async function runScan(options) {
61
64
  await stage('decide', { done: index, total: candidates.length });
62
65
  let row;
63
66
  try {
64
- const created = transition(undefined, { type: 'candidate', a: candidate.a, b: candidate.b, at: at(options.now) });
65
- if (!created.ok || created.row === null)
66
- throw new Error(created.ok ? 'candidate row missing' : created.message);
67
- row = options.runId === undefined ? created.row : { ...created.row, runId: options.runId };
67
+ const key = pairKey(candidate.a, candidate.b);
68
+ const atNow = at(options.now);
69
+ row = { id: rowIdOf(key), pairKey: key, a: candidate.a, b: candidate.b, state: 'verify', queue: 'verify', createdAt: atNow, updatedAt: atNow, ...(options.runId === undefined ? {} : { runId: options.runId }) };
68
70
  const before = (await loadMergeState(options.io, options.home)).state.rows.find(item => item.pairKey === row.pairKey);
69
71
  if (before?.state === 'verify' && before.runId !== undefined && before.runId !== options.runId
70
72
  && (await options.isOwnerActive?.(before.runId)) === true) {
71
73
  continue;
72
74
  }
73
- const placed = await upsertRow(options.io, options.home, options.now, row, 'verify', before?.updatedAt ?? null);
74
- if (!placed.ok && before?.state === 'skipped') {
75
- const restored = transition(before, { type: 'restore', at: at(options.now) });
76
- if (!restored.ok || restored.row === null)
77
- throw new Error('cannot restore skipped pair');
78
- const persisted = await upsertRow(options.io, options.home, options.now, restored.row, 'any', before.updatedAt);
79
- if (!persisted.ok)
80
- throw new Error('pair changed while restoring skipped row');
81
- row = restored.row;
82
- }
83
- else if (!placed.ok) {
84
- throw new Error('pair already in ' + placed.current.state);
85
- }
75
+ const status = await transactRows(options.io, options.home, tx => {
76
+ const current = tx.rows().find(item => item.pairKey === row.pairKey);
77
+ if (current !== undefined && current.state !== 'verify' && current.state !== 'skipped')
78
+ return 'conflict:' + current.state;
79
+ if (current?.state === 'skipped') {
80
+ const removed = tx.transition(current.id, { type: 'restored' }, current.version);
81
+ if (!removed.ok)
82
+ return 'restore-failed';
83
+ }
84
+ if (current?.state === 'verify')
85
+ return 'existing';
86
+ const made = tx.createVerify({
87
+ id: current?.id ?? rowIdOf(row.pairKey), pairKey: row.pairKey, a: row.a, b: row.b,
88
+ version: 0, createdAt: at(options.now), updatedAt: at(options.now), origin: 'agent',
89
+ state: 'verify', verifySubState: 'unverified', attempts: 0,
90
+ ...(row.runId === undefined ? {} : { runId: row.runId }),
91
+ });
92
+ return made.ok ? 'created' : 'invalid:' + made.code;
93
+ });
94
+ if (status.startsWith('conflict:'))
95
+ throw new Error('pair already in ' + status.slice(9));
96
+ if (status === 'restore-failed' || status.startsWith('invalid:'))
97
+ throw new Error('cannot place verify row: ' + status);
86
98
  }
87
99
  catch (error) {
88
100
  totals.failed += 1;
@@ -105,22 +117,39 @@ export async function runScan(options) {
105
117
  break;
106
118
  }
107
119
  if (round1.kind === 'failed') {
108
- const next = transition(row, { type: 'round1-fail', failure: round1.failure ?? failureOf('decide', 'round1 failed'), at: at(options.now) });
109
- if (next.ok && next.row !== null) {
110
- row = next.row;
111
- await upsertRow(options.io, options.home, options.now, row);
120
+ const failure = round1.failure ?? failureOf('decide', 'round1 failed');
121
+ const projected = await transactRows(options.io, options.home, tx => {
122
+ const current = tx.rows().find(item => item.id === row.id);
123
+ if (current === undefined)
124
+ return null;
125
+ const moved = tx.transition(row.id, { type: 'failed', failure }, current.version);
126
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
127
+ });
128
+ if (projected === null) {
129
+ pairs.push(pairOf(row, candidate, 'failed', { failure: failureOf('decide', 'row changed before failure record') }));
130
+ totals.failed += 1;
131
+ continue;
112
132
  }
113
- pairs.push(pairOf(row, candidate, 'failed', { failure: round1.failure ?? failureOf('decide', 'round1 failed') }));
133
+ row = projected;
134
+ pairs.push(pairOf(row, candidate, 'failed', { failure }));
114
135
  totals.failed += 1;
115
136
  continue;
116
137
  }
117
138
  if (round1.kind === 'keep') {
118
- const next = transition(row, { type: 'round1-keep', at: at(options.now) });
119
- if (next.ok && next.row !== null) {
120
- row = next.row;
121
- await upsertRow(options.io, options.home, options.now, row);
122
- await recordPair(options.io, options.home, { pairKey: row.pairKey, runId: row.runId ?? 'merge', result: 'kept' });
139
+ const projected = await transactRows(options.io, options.home, tx => {
140
+ const current = tx.rows().find(item => item.id === row.id);
141
+ if (current === undefined)
142
+ return null;
143
+ const moved = tx.transition(row.id, { type: 'skipped', reason: 'kept' }, current.version);
144
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
145
+ });
146
+ if (projected === null) {
147
+ pairs.push(pairOf(row, candidate, 'failed', { failure: failureOf('decide', 'row changed before keep record') }));
148
+ totals.failed += 1;
149
+ continue;
123
150
  }
151
+ row = projected;
152
+ await recordPair(options.io, options.home, { pairKey: row.pairKey, runId: row.runId ?? 'merge', result: 'kept' });
124
153
  pairs.push(pairOf(row, candidate, 'kept', round1.decision === undefined ? {} : { decision: round1.decision }));
125
154
  totals.kept += 1;
126
155
  continue;
@@ -131,29 +160,54 @@ export async function runScan(options) {
131
160
  continue;
132
161
  }
133
162
  if (options.approval) {
134
- const next = transition(row, { type: 'round1-merge', approval: 'on', decision: round1.decision, ...(round1.sourceRevision === undefined ? {} : { sourceRevision: round1.sourceRevision }), ...(round1.targetRevision === undefined ? {} : { targetRevision: round1.targetRevision }), at: at(options.now) });
135
- if (next.ok && next.row !== null) {
136
- row = next.row;
137
- await upsertRow(options.io, options.home, options.now, row);
163
+ const event = { type: 'verify-passed', decision: round1.decision, target: round1.decision.target, ...(round1.sourceRevision === undefined ? {} : { sourceRevision: round1.sourceRevision }) };
164
+ const projected = await transactRows(options.io, options.home, tx => {
165
+ const current = tx.rows().find(item => item.id === row.id);
166
+ if (current === undefined)
167
+ return null;
168
+ const moved = tx.transition(row.id, event, current.version);
169
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
170
+ });
171
+ if (projected === null) {
172
+ pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failureOf('decide', 'row changed before pending record') }));
173
+ totals.failed += 1;
174
+ continue;
138
175
  }
176
+ row = projected;
139
177
  pairs.push(pairOf(row, candidate, 'pending', { decision: round1.decision }));
140
178
  totals.pending += 1;
141
179
  continue;
142
180
  }
143
- const fuse = transition(row, { type: 'round1-merge', approval: 'off', decision: round1.decision, ...(round1.stagingRef === undefined ? {} : { stagingRef: round1.stagingRef }), ...(round1.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: round1.stagingExpiresAt }), at: at(options.now) });
144
- if (!fuse.ok || fuse.row === null) {
145
- pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failureOf('decide', 'cannot enter fuse') }));
181
+ const staged = await transactRows(options.io, options.home, tx => {
182
+ const current = tx.rows().find(item => item.id === row.id);
183
+ if (current === undefined)
184
+ return null;
185
+ const approved = tx.transition(row.id, { type: 'auto-approved', decision: round1.decision, target: round1.decision.target, ...(row.runId === undefined ? {} : { runId: row.runId }) }, current.version);
186
+ if (!approved.ok || approved.row === null)
187
+ return null;
188
+ const moved = tx.transition(row.id, { type: 'staged', ...(round1.stagingRef === undefined ? {} : { ref: round1.stagingRef }), ...(round1.stagingExpiresAt === undefined ? {} : { expiresAt: round1.stagingExpiresAt }) }, approved.row.version);
189
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
190
+ });
191
+ if (staged === null) {
192
+ pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failureOf('author', 'row changed before staging record') }));
146
193
  totals.failed += 1;
147
194
  continue;
148
195
  }
149
- row = fuse.row;
150
- await upsertRow(options.io, options.home, options.now, row);
196
+ row = staged;
197
+ if (round1.stagingRef !== undefined) {
198
+ const expiresAt = round1.stagingExpiresAt ?? options.now() + 7 * 86_400_000;
199
+ await writeStagingIndex(options.io, options.home, { rowId: row.id, pairKey: row.pairKey, ref: round1.stagingRef, expiresAt, ...(row.runId === undefined ? {} : { runId: row.runId }), createdAt: options.now() });
200
+ }
151
201
  if (isAborted(options.signal)) {
152
- const cancelled = transition(row, { type: 'round2-cancel', at: at(options.now) });
153
- if (cancelled.ok && cancelled.row !== null) {
154
- row = cancelled.row;
155
- await upsertRow(options.io, options.home, options.now, row);
156
- }
202
+ const projected = await transactRows(options.io, options.home, tx => {
203
+ const current = tx.rows().find(item => item.id === row.id);
204
+ if (current === undefined)
205
+ return null;
206
+ const moved = tx.transition(row.id, { type: 'skipped', reason: 'cancelled' }, current.version);
207
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
208
+ });
209
+ if (projected !== null)
210
+ row = projected;
157
211
  stopReason = 'cancelled';
158
212
  remaining.push(...candidates.slice(index).map(candidate => ({ pairKey: pairKey(candidate.a, candidate.b), queue: 'fuse' })));
159
213
  break;
@@ -167,9 +221,12 @@ export async function runScan(options) {
167
221
  round2 = { kind: 'failed', failure: failureOf('author', error) };
168
222
  }
169
223
  if (round2.kind === 'merged') {
170
- const success = transition(row, { type: 'round2-success', at: at(options.now) });
171
- if (success.ok)
172
- await dropRow(options.io, options.home, options.now, row.id);
224
+ const dropped = await dropRowV3(options.io, options.home, row.id, 'committed', round2.unitId ?? 'unknown');
225
+ if (!dropped) {
226
+ pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failureOf('commit', 'row changed before commit record') }));
227
+ totals.failed += 1;
228
+ continue;
229
+ }
173
230
  pairs.push(pairOf(row, candidate, 'merged', {
174
231
  decision: round1.decision,
175
232
  ...(round2.unitId === undefined ? {} : { unitId: round2.unitId }),
@@ -180,11 +237,19 @@ export async function runScan(options) {
180
237
  continue;
181
238
  }
182
239
  const failed = round2.failure ?? failureOf('author', 'round2 failed');
183
- const next = transition(row, { type: 'round2-fail', failure: failed, at: at(options.now) });
184
- if (next.ok && next.row !== null) {
185
- row = next.row;
186
- await upsertRow(options.io, options.home, options.now, row);
240
+ const projected = await transactRows(options.io, options.home, tx => {
241
+ const current = tx.rows().find(item => item.id === row.id);
242
+ if (current === undefined)
243
+ return null;
244
+ const moved = tx.transition(row.id, { type: 'failed', failure: failed }, current.version);
245
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
246
+ });
247
+ if (projected === null) {
248
+ pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failureOf('author', 'row changed before failure record') }));
249
+ totals.failed += 1;
250
+ continue;
187
251
  }
252
+ row = projected;
188
253
  pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failed }));
189
254
  totals.failed += 1;
190
255
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The real screening port: lexical floor + optional C3 proposal + optional L0–L2 gate.
3
+ * Each stage is independently testable; this file only composes their frozen contracts.
4
+ */
5
+ import { type MergeCandidate } from './contracts.ts';
6
+ import { type SkillCard, type ScreeningOptions } from './screening.ts';
7
+ import { type ModelPort } from './propose.ts';
8
+ import type { GateCache, GateModelPort } from './ladder.ts';
9
+ export interface ScreeningForRunOptions extends Omit<BuildScreeningOptions, 'signal'> {
10
+ readonly runSeed: string;
11
+ }
12
+ export interface BuildScreeningOptions {
13
+ readonly cards: readonly SkillCard[];
14
+ readonly signal: AbortSignal;
15
+ readonly lexical?: ScreeningOptions;
16
+ readonly propose?: {
17
+ readonly model: ModelPort;
18
+ readonly shardSize?: number;
19
+ readonly maxPairs?: number;
20
+ };
21
+ readonly gate?: {
22
+ readonly model: GateModelPort;
23
+ readonly modelName: string;
24
+ readonly runSeed: string;
25
+ readonly cache?: GateCache;
26
+ readonly auditRate?: number;
27
+ };
28
+ }
29
+ export interface ScreeningPortStats {
30
+ readonly lexical: number;
31
+ readonly proposed: number;
32
+ readonly candidates: number;
33
+ readonly kept: number;
34
+ readonly dropped: number;
35
+ readonly gateCalls: number;
36
+ readonly gateErrors: number;
37
+ readonly shards: number;
38
+ }
39
+ export interface ScreeningPortResult {
40
+ readonly candidates: readonly MergeCandidate[];
41
+ readonly stats: ScreeningPortStats;
42
+ readonly degraded: readonly string[];
43
+ }
44
+ export declare function buildScreeningCandidates(options: BuildScreeningOptions): Promise<ScreeningPortResult>;
45
+ /** Build the run-scoped screening function; the run id seeds the 10% audit. */
46
+ export declare function createScreeningForRun(options: ScreeningForRunOptions): (signal: AbortSignal) => Promise<readonly MergeCandidate[]>;
47
+ //# sourceMappingURL=screening-port.d.ts.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The real screening port: lexical floor + optional C3 proposal + optional L0–L2 gate.
3
+ * Each stage is independently testable; this file only composes their frozen contracts.
4
+ */
5
+ import { pairKey } from "./contracts.js";
6
+ import { lexicalCandidates } from "./screening.js";
7
+ import { proposePairs } from "./propose.js";
8
+ import { filterCandidates } from "./gate-filter.js";
9
+ export async function buildScreeningCandidates(options) {
10
+ const lexical = lexicalCandidates(options.cards, options.lexical);
11
+ const merged = new Map();
12
+ for (const candidate of lexical.candidates)
13
+ merged.set(pairKey(candidate.a, candidate.b), candidate);
14
+ const degraded = [];
15
+ let proposed = 0;
16
+ let shards = 0;
17
+ if (options.propose !== undefined) {
18
+ const proposal = await proposePairs(options.cards, options.propose.model, {
19
+ signal: options.signal,
20
+ ...(options.propose.shardSize === undefined ? {} : { shardSize: options.propose.shardSize }),
21
+ ...(options.propose.maxPairs === undefined ? {} : { maxPairs: options.propose.maxPairs }),
22
+ });
23
+ proposed = proposal.candidates.length;
24
+ shards = proposal.shards;
25
+ degraded.push(...proposal.degraded);
26
+ for (const candidate of proposal.candidates) {
27
+ const key = pairKey(candidate.a, candidate.b);
28
+ const existing = merged.get(key);
29
+ if (existing === undefined) {
30
+ merged.set(key, candidate);
31
+ continue;
32
+ }
33
+ const arms = [...new Set([...(existing.arms ?? []), ...(candidate.arms ?? [])])];
34
+ merged.set(key, {
35
+ ...existing,
36
+ ...(candidate.reason === undefined || existing.reason !== undefined ? {} : { reason: candidate.reason }),
37
+ ...(arms.length === 0 ? {} : { arms }),
38
+ });
39
+ }
40
+ }
41
+ let candidates = [...merged.values()].sort((left, right) => (right.score ?? 0) - (left.score ?? 0)
42
+ || left.a.localeCompare(right.a)
43
+ || left.b.localeCompare(right.b));
44
+ let kept = candidates.length;
45
+ let dropped = 0;
46
+ let gateCalls = 0;
47
+ let gateErrors = 0;
48
+ if (options.gate !== undefined) {
49
+ const facts = options.cards.map(card => ({ name: card.name, description: card.description, body: card.body }));
50
+ const filtered = await filterCandidates(candidates, {
51
+ facts,
52
+ model: options.gate.model,
53
+ modelName: options.gate.modelName,
54
+ signal: options.signal,
55
+ runSeed: options.gate.runSeed,
56
+ ...(options.gate.cache === undefined ? {} : { cache: options.gate.cache }),
57
+ ...(options.gate.auditRate === undefined ? {} : { auditRate: options.gate.auditRate }),
58
+ });
59
+ candidates = filtered.kept.map(decision => decision.candidate);
60
+ kept = filtered.kept.length;
61
+ dropped = filtered.dropped.length;
62
+ gateCalls = filtered.calls;
63
+ gateErrors = filtered.errors.length;
64
+ for (const error of filtered.errors)
65
+ degraded.push('gate error: ' + error.candidate.a + ' + ' + error.candidate.b + ': ' + error.message);
66
+ }
67
+ return {
68
+ candidates,
69
+ stats: {
70
+ lexical: lexical.candidates.length,
71
+ proposed,
72
+ candidates: merged.size,
73
+ kept,
74
+ dropped,
75
+ gateCalls,
76
+ gateErrors,
77
+ shards,
78
+ },
79
+ degraded,
80
+ };
81
+ }
82
+ /** Build the run-scoped screening function; the run id seeds the 10% audit. */
83
+ export function createScreeningForRun(options) {
84
+ const gate = options.gate === undefined ? undefined : { ...options.gate, runSeed: options.runSeed };
85
+ return async (signal) => (await buildScreeningCandidates({
86
+ cards: options.cards,
87
+ signal,
88
+ ...(options.lexical === undefined ? {} : { lexical: options.lexical }),
89
+ ...(options.propose === undefined ? {} : { propose: options.propose }),
90
+ ...(gate === undefined ? {} : { gate }),
91
+ })).candidates;
92
+ }
93
+ //# sourceMappingURL=screening-port.js.map
@@ -0,0 +1,30 @@
1
+ import type { MergeCandidate } from './contracts.ts';
2
+ export interface SkillCard {
3
+ readonly name: string;
4
+ readonly description: string;
5
+ readonly body: string;
6
+ }
7
+ export interface ScreeningOptions {
8
+ /** Body Jaccard at/above this is a candidate on its own. */
9
+ readonly bodyGate?: number;
10
+ /** Description Jaccard at/above this is a candidate when the body also clears minAffinity. */
11
+ readonly summaryGate?: number;
12
+ /** Minimum body affinity for a summary-led candidate. */
13
+ readonly minAffinity?: number;
14
+ /** Upper bound after sorting by score; absent = no cap. */
15
+ readonly maxCandidates?: number;
16
+ }
17
+ export interface ScreeningStats {
18
+ readonly skills: number;
19
+ readonly filtered: number;
20
+ readonly candidates: number;
21
+ }
22
+ export interface ScreeningResult {
23
+ readonly candidates: readonly MergeCandidate[];
24
+ readonly stats: ScreeningStats;
25
+ }
26
+ /** Shannon entropy over characters; a cheap prefilter signal, not a name-quality judge. */
27
+ export declare function nameEntropy(name: string): number;
28
+ export declare function prefilter(cards: readonly SkillCard[]): readonly SkillCard[];
29
+ export declare function lexicalCandidates(cards: readonly SkillCard[], options?: ScreeningOptions): ScreeningResult;
30
+ //# sourceMappingURL=screening.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Deterministic candidate screening: name prefilter + lexical affinity.
3
+ *
4
+ * The semantic proposer (C3) is a separate port; this module is the zero-cost floor that
5
+ * must work without a model. It reuses core `vocabulary`/`affinity` so drift/dedup and
6
+ * merge screening share one tokenizer.
7
+ */
8
+ import { affinity, vocabulary } from '@lmzhen/dsh-evolution-core';
9
+ /** Shannon entropy over characters; a cheap prefilter signal, not a name-quality judge. */
10
+ export function nameEntropy(name) {
11
+ if (name === '')
12
+ return 0;
13
+ const counts = new Map();
14
+ for (const char of name)
15
+ counts.set(char, (counts.get(char) ?? 0) + 1);
16
+ let entropy = 0;
17
+ for (const count of counts.values()) {
18
+ const p = count / name.length;
19
+ entropy -= p * Math.log2(p);
20
+ }
21
+ return entropy;
22
+ }
23
+ const MIN_NAME_ENTROPY = 1.5;
24
+ const MIN_NAME_LENGTH = 6;
25
+ export function prefilter(cards) {
26
+ return cards.filter((card) => {
27
+ const name = card.name.trim();
28
+ if (name.length < MIN_NAME_LENGTH)
29
+ return false;
30
+ if (nameEntropy(name) < MIN_NAME_ENTROPY)
31
+ return false;
32
+ return card.body.trim() !== '' || card.description.trim() !== '';
33
+ });
34
+ }
35
+ function sortedPair(a, b) {
36
+ return a < b ? [a, b] : [b, a];
37
+ }
38
+ export function lexicalCandidates(cards, options = {}) {
39
+ const bodyGate = options.bodyGate ?? 0.9;
40
+ const summaryGate = options.summaryGate ?? 0.5;
41
+ const minAffinity = options.minAffinity ?? 0.3;
42
+ const eligible = prefilter(cards);
43
+ const vectors = eligible.map(card => ({
44
+ card,
45
+ summary: vocabulary(card.description),
46
+ body: vocabulary(card.body),
47
+ }));
48
+ const candidates = [];
49
+ for (let i = 0; i < vectors.length; i += 1) {
50
+ for (let j = i + 1; j < vectors.length; j += 1) {
51
+ const left = vectors[i];
52
+ const right = vectors[j];
53
+ if (left === undefined || right === undefined)
54
+ continue;
55
+ const summary = affinity(left.summary, right.summary);
56
+ const body = affinity(left.body, right.body);
57
+ const arms = [];
58
+ if (body >= bodyGate)
59
+ arms.push('body');
60
+ if (summary >= summaryGate && body >= minAffinity)
61
+ arms.push('summary');
62
+ if (arms.length === 0)
63
+ continue;
64
+ const [a, b] = sortedPair(left.card.name, right.card.name);
65
+ candidates.push({ a, b, score: Math.max(body, summary), arms, source: 'lexical' });
66
+ }
67
+ }
68
+ candidates.sort((left, right) => (right.score ?? 0) - (left.score ?? 0) || left.a.localeCompare(right.a) || left.b.localeCompare(right.b));
69
+ const capped = options.maxCandidates === undefined ? candidates : candidates.slice(0, options.maxCandidates);
70
+ return {
71
+ candidates: capped,
72
+ stats: { skills: cards.length, filtered: cards.length - eligible.length, candidates: capped.length },
73
+ };
74
+ }
75
+ //# sourceMappingURL=screening.js.map
@@ -1,5 +1,7 @@
1
1
  import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
2
2
  import { type MergePairState, type MergeQueue, type MergeRow, type MergeState } from './contracts.ts';
3
+ import { type RowWriteResult } from './domain/row-tx.ts';
4
+ import { type SkippedRowV3, type V3Event, type V3Row, type VerifyRowV3 } from './domain/row-v3.ts';
3
5
  export declare function mergeStatePath(home: string): string;
4
6
  export interface LoadMergeStateResult {
5
7
  readonly ok: boolean;
@@ -8,6 +10,16 @@ export interface LoadMergeStateResult {
8
10
  }
9
11
  /** Pure read: never writes, never deletes a row. */
10
12
  export declare function loadMergeState(io: EvolutionIoLike, home: string): Promise<LoadMergeStateResult>;
13
+ /** Schema probe for doctor; the only other reader of mergeStatePath. */
14
+ export declare function loadStateSchema(io: EvolutionIoLike, home: string): Promise<number | null>;
15
+ export interface RowTx {
16
+ rows(): readonly V3Row[];
17
+ createVerify(row: VerifyRowV3): RowWriteResult;
18
+ createSkipped(row: SkippedRowV3): RowWriteResult;
19
+ transition(id: string, event: V3Event, expectedVersion: number): RowWriteResult;
20
+ }
21
+ /** G3-5 runtime write API: only createVerify/createSkipped/transition. v2 writes are rejected. */
22
+ export declare function transactRows<T>(io: EvolutionIoLike, home: string, fn: (tx: RowTx) => T): Promise<T>;
11
23
  /** The ONE write point. The callback returns the whole row set, not a delta. */
12
24
  export declare function mutateMergeState<T>(io: EvolutionIoLike, home: string, now: () => number, fn: (rows: readonly MergeRow[]) => {
13
25
  rows: readonly MergeRow[];
@@ -31,6 +43,8 @@ export type UpsertResult = {
31
43
  };
32
44
  export declare function upsertRow(io: EvolutionIoLike, home: string, now: () => number, row: MergeRow, expect?: UpsertExpect, cas?: string | null): Promise<UpsertResult>;
33
45
  export declare function moveRow(io: EvolutionIoLike, home: string, now: () => number, id: string, patch: MoveRowPatch): Promise<MergeRow | undefined>;
46
+ /** G3-5 v3-aware drop: only terminal events. */
47
+ export declare function dropRowV3(io: EvolutionIoLike, home: string, id: string, event: 'committed' | 'superseded' | 'restored', unitId?: string): Promise<boolean>;
34
48
  export declare function dropRow(io: EvolutionIoLike, home: string, now: () => number, id: string): Promise<boolean>;
35
49
  export declare function rowsOf(io: EvolutionIoLike, home: string, queue?: MergeQueue): Promise<readonly MergeRow[]>;
36
50
  //# sourceMappingURL=state.d.ts.map
@@ -10,6 +10,10 @@ import { join } from 'node:path';
10
10
  import { transactIo } from '@lmzhen/dsh-evolution-core';
11
11
  import { MERGE_STATE_VERSION, queueOf, } from "./contracts.js";
12
12
  import { parseLedger } from "./domain/ledger.js";
13
+ import { legacyToV3 } from "./domain/row-v3-bridge.js";
14
+ import { applyRowWrite } from "./domain/row-tx.js";
15
+ import { isV3Row } from "./domain/row-v3.js";
16
+ import { deleteStagingIndex } from "./domain/staging-index.js";
13
17
  export function mergeStatePath(home) {
14
18
  return join(home, 'merge', 'merge-state.json');
15
19
  }
@@ -39,6 +43,55 @@ export async function loadMergeState(io, home) {
39
43
  ...(parsed.note === undefined ? {} : { note: parsed.note }),
40
44
  };
41
45
  }
46
+ /** Schema probe for doctor; the only other reader of mergeStatePath. */
47
+ export async function loadStateSchema(io, home) {
48
+ try {
49
+ const raw = await io.readText(mergeStatePath(home));
50
+ if (raw === null)
51
+ return null;
52
+ const parsed = JSON.parse(raw);
53
+ return typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /** G3-5 runtime write API: only createVerify/createSkipped/transition. v2 writes are rejected. */
60
+ export async function transactRows(io, home, fn) {
61
+ let result;
62
+ await transactIo(io, mergeStatePath(home), (current) => {
63
+ let rows = [];
64
+ if (current !== null) {
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(current);
68
+ }
69
+ catch {
70
+ throw new Error('merge-state-needs-repair');
71
+ }
72
+ const body = parsed;
73
+ if (body.schemaVersion !== 3 || !Array.isArray(body.rows) || !body.rows.every(isV3Row)) {
74
+ throw new Error(body.schemaVersion === 2 ? 'merge-state-migration-required' : 'merge-state-needs-repair');
75
+ }
76
+ rows = body.rows;
77
+ }
78
+ const apply = (write) => {
79
+ const next = applyRowWrite(rows, write, new Date().toISOString());
80
+ if (next.ok)
81
+ rows = next.rows;
82
+ return next;
83
+ };
84
+ const tx = {
85
+ rows: () => rows,
86
+ createVerify: row => apply({ kind: 'createVerify', row }),
87
+ createSkipped: row => apply({ kind: 'createSkipped', row }),
88
+ transition: (id, event, expectedVersion) => apply({ kind: 'transition', id, event, expectedVersion }),
89
+ };
90
+ result = fn(tx);
91
+ return JSON.stringify({ schemaVersion: 3, rows }, null, 2);
92
+ });
93
+ return result;
94
+ }
42
95
  /** The ONE write point. The callback returns the whole row set, not a delta. */
43
96
  export async function mutateMergeState(io, home, now, fn) {
44
97
  let result;
@@ -46,9 +99,14 @@ export async function mutateMergeState(io, home, now, fn) {
46
99
  await transactIo(io, mergeStatePath(home), (current) => {
47
100
  ran = true;
48
101
  const parsed = parseState(current);
102
+ if (!parsed.ok || parsed.note === 'merge-state-forward-only')
103
+ throw new Error(parsed.note ?? 'merge-state-invalid');
49
104
  const out = fn(parsed.rows);
50
105
  result = out.result;
51
- return JSON.stringify({ schemaVersion: MERGE_STATE_VERSION, rows: out.rows }, null, 2);
106
+ const rows = out.rows.map(row => legacyToV3(row));
107
+ if (rows.some(row => row === null))
108
+ throw new Error('merge-state-v3-row-invalid');
109
+ return JSON.stringify({ schemaVersion: 3, rows }, null, 2);
52
110
  });
53
111
  // transactIo only returns after fn ran; a skipped write is not a state outcome.
54
112
  void ran;
@@ -100,6 +158,19 @@ export async function moveRow(io, home, now, id, patch) {
100
158
  return { rows: copy, result: next };
101
159
  });
102
160
  }
161
+ /** G3-5 v3-aware drop: only terminal events. */
162
+ export async function dropRowV3(io, home, id, event, unitId = 'unknown') {
163
+ const dropped = await transactRows(io, home, tx => {
164
+ const row = tx.rows().find(item => item.id === id);
165
+ if (row === undefined)
166
+ return false;
167
+ const moved = tx.transition(id, event === 'committed' ? { type: 'committed', unitId } : { type: event }, row.version);
168
+ return moved.ok;
169
+ });
170
+ if (dropped)
171
+ await deleteStagingIndex(io, home, id);
172
+ return dropped;
173
+ }
103
174
  export async function dropRow(io, home, now, id) {
104
175
  return await mutateMergeState(io, home, now, (rows) => {
105
176
  const next = rows.filter(item => item.id !== id);
@@ -3,6 +3,7 @@
3
3
  * writes, no report reads. The UI and command parser share this vocabulary.
4
4
  */
5
5
  import type { MergeQueue, MergeRow } from './contracts.ts';
6
+ import type { Facts, RunsView } from './wire.ts';
6
7
  export interface MergeCounts {
7
8
  readonly verify: number;
8
9
  readonly pending: number;
@@ -11,5 +12,7 @@ export interface MergeCounts {
11
12
  }
12
13
  export declare function countRows(rows: readonly MergeRow[]): MergeCounts;
13
14
  export declare function formatStatus(rows: readonly MergeRow[]): string;
15
+ export declare function formatFacts(facts: Facts): string;
16
+ export declare function formatRunsView(view: RunsView): string;
14
17
  export declare function formatQueue(rows: readonly MergeRow[], queue: MergeQueue): string;
15
18
  //# sourceMappingURL=status.d.ts.map