verifyhash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. package/trustledger/valueproof.js +350 -0
@@ -0,0 +1,551 @@
1
+ "use strict";
2
+
3
+ // TrustLedger — match.js
4
+ //
5
+ // T-22.2: the EXACT-then-FUZZY transaction matcher.
6
+ //
7
+ // Reconciliation is, at its core, a bipartite matching problem: each line on
8
+ // one statement (e.g. the BANK) should correspond to one — or, for a batched
9
+ // deposit, SEVERAL — lines on the other (e.g. the QuickBooks/rent ledger). The
10
+ // real world makes this hard in three recurring ways a property manager hits
11
+ // EVERY month:
12
+ //
13
+ // 1. TIMING. A tenant's payment posts to the ledger on the 1st but clears the
14
+ // bank on the 2nd or 3rd. Same money, different date. We need a tolerant
15
+ // date window, not an equality test.
16
+ //
17
+ // 2. SPLIT / BATCHED DEPOSITS. The manager walks three rent checks to the
18
+ // bank and they land as ONE $4,500 bank credit, while the ledger has three
19
+ // separate $1,500 tenant payments. One bank line must match a SET of
20
+ // ledger lines whose amounts sum to it.
21
+ //
22
+ // 3. NSF REVERSALS. A deposit bounces; the bank posts a NEGATIVE reversal of
23
+ // the exact (or near) amount. Both the original and the reversal must be
24
+ // reconciled so the net effect is visible, not silently netted away.
25
+ //
26
+ // This module is a PURE, DETERMINISTIC function of its two input arrays and the
27
+ // options. No clock, no randomness, no I/O. Given the same inputs it returns
28
+ // byte-identical output regardless of the ORDER of either list — a property the
29
+ // tests assert directly, because a reconciliation a broker signs must be
30
+ // reproducible.
31
+ //
32
+ // Return shape:
33
+ // {
34
+ // matched: [ { a, b, confidence, kind } , ... ],
35
+ // unmatchedA: [ <record>, ... ],
36
+ // unmatchedB: [ <record>, ... ],
37
+ // }
38
+ // where in each pairing exactly ONE side is a single record and the other side
39
+ // MAY be an array (the split). `a` always refers to a record (or array) drawn
40
+ // from list A, `b` from list B. `confidence` is a 0..1 number; `kind` is a short
41
+ // machine string explaining WHY it matched ("exact", "amount+window",
42
+ // "split", "nsf-reversal").
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Tunables (all overridable via opts)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const DEFAULTS = Object.freeze({
49
+ // How many calendar days the two sides of a pairing may differ by and still
50
+ // be considered the same event. 0 => same-day only.
51
+ dateToleranceDays: 3,
52
+
53
+ // Amounts are integer cents and must match EXACTLY in magnitude — money does
54
+ // not "almost" reconcile. This is a guard against a future caller passing a
55
+ // float tolerance; we keep it 0 and reject non-zero with a clear error rather
56
+ // than silently fuzzing dollars.
57
+ amountToleranceCents: 0,
58
+
59
+ // Largest number of B-records allowed to combine into one A-record (or vice
60
+ // versa) for a split/batched deposit. Bounds the combinatorial search so a
61
+ // pathological month can never blow up; a single deposit batching more than
62
+ // this many tenant payments is vanishingly rare and better surfaced as an
63
+ // exception than matched by brute force.
64
+ maxSplitParts: 6,
65
+
66
+ // Largest WINDOWED, same-sign candidate POOL we will brute-force a split over
67
+ // for a single record. maxSplitParts bounds the DEPTH of the search; this
68
+ // bounds its BREADTH. When many unmatched same-sign lines cluster inside one
69
+ // date window (the real-world shape on the 1st-5th of a month, when most rent
70
+ // posts), C(n, k) explodes even though each subset is shallow. Above this
71
+ // pool size we DECLINE the brute-force split and leave the record as an
72
+ // exception for a human — the same "surface it rather than guess" philosophy
73
+ // as maxSplitParts, applied to the dimension that actually blows up. A real
74
+ // batched deposit almost never draws from more than a couple dozen same-day,
75
+ // same-sign candidates; a pool larger than this is itself a signal the file
76
+ // needs a human eye, not a 2-minute combinatorial grind.
77
+ maxSplitCandidates: 24,
78
+
79
+ // Minimum memo similarity (0..1) required to LOWER confidence vs. raise it;
80
+ // memo never blocks a same-amount/in-window match, it only modulates the
81
+ // reported confidence so a reviewer can sort by it.
82
+ memoWeight: 0.25,
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Small deterministic helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ // Whole-day difference between two "YYYY-MM-DD" strings (absolute value).
90
+ // Pure calendar arithmetic via UTC epoch days — no timezone, no Date-locale.
91
+ function dayDiff(isoA, isoB) {
92
+ return Math.abs(epochDay(isoA) - epochDay(isoB));
93
+ }
94
+
95
+ function epochDay(iso) {
96
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
97
+ if (!m) throw new MatchError(`bad date in record: "${iso}"`);
98
+ const y = Number(m[1]);
99
+ const mo = Number(m[2]);
100
+ const d = Number(m[3]);
101
+ // Days from a fixed civil epoch (Howard Hinnant's algorithm). Deterministic,
102
+ // leap-correct, and independent of the host Date implementation.
103
+ const yy = mo <= 2 ? y - 1 : y;
104
+ const era = Math.floor((yy >= 0 ? yy : yy - 399) / 400);
105
+ const yoe = yy - era * 400;
106
+ const doy = Math.floor((153 * (mo > 2 ? mo - 3 : mo + 9) + 2) / 5) + d - 1;
107
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
108
+ return era * 146097 + doe - 719468;
109
+ }
110
+
111
+ // Normalize a memo for comparison: lowercase, collapse whitespace, drop
112
+ // punctuation. Deterministic.
113
+ function normMemo(s) {
114
+ return String(s == null ? "" : s)
115
+ .toLowerCase()
116
+ .replace(/[^a-z0-9 ]+/g, " ")
117
+ .replace(/\s+/g, " ")
118
+ .trim();
119
+ }
120
+
121
+ // Token-set Jaccard similarity of two memos, 0..1. Empty-vs-empty => 1 (no
122
+ // information either way is treated as neutral-high so blank memos don't punish
123
+ // an otherwise-perfect amount/date match). Empty-vs-nonempty => 0.
124
+ function memoSimilarity(a, b) {
125
+ const na = normMemo(a);
126
+ const nb = normMemo(b);
127
+ if (na === "" && nb === "") return 1;
128
+ if (na === "" || nb === "") return 0;
129
+ const sa = new Set(na.split(" "));
130
+ const sb = new Set(nb.split(" "));
131
+ let inter = 0;
132
+ for (const t of sa) if (sb.has(t)) inter++;
133
+ const union = sa.size + sb.size - inter;
134
+ return union === 0 ? 0 : inter / union;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Errors
139
+ // ---------------------------------------------------------------------------
140
+
141
+ class MatchError extends Error {
142
+ constructor(message) {
143
+ super(message);
144
+ this.name = "MatchError";
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Stable identity / ordering
150
+ //
151
+ // To be order-independent yet deterministic we (1) tag every input record with
152
+ // its original index, (2) sort a STABLE working copy by an intrinsic key
153
+ // (date, amount, memo, original index), and (3) always iterate that sorted copy.
154
+ // Two callers passing the same multiset of records in different orders therefore
155
+ // walk the SAME sequence of decisions and produce the same pairings.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ function sortKey(rec) {
159
+ // Intrinsic, input-order-free key. Original index is the FINAL tiebreak only
160
+ // for genuinely identical records (same date/amount/memo), where the choice
161
+ // cannot affect amounts and is therefore reconciliation-neutral.
162
+ return [rec.date, pad(rec.amount), normMemo(rec.memo)];
163
+ }
164
+
165
+ // Zero-pad a signed integer so lexical sort == numeric sort.
166
+ function pad(n) {
167
+ const neg = n < 0;
168
+ const s = String(Math.abs(n)).padStart(15, "0");
169
+ // "-" sorts before "0" in ASCII, but we want -100 < -1 < 0 < 1; invert the
170
+ // magnitude ordering for negatives by mapping to a complement.
171
+ return neg ? "0:" + complement(s) : "1:" + s;
172
+ }
173
+ function complement(digits) {
174
+ // Nine's-complement so larger magnitude negatives sort first.
175
+ let out = "";
176
+ for (const c of digits) out += String(9 - Number(c));
177
+ return out;
178
+ }
179
+
180
+ function cmpKey(a, b) {
181
+ const ka = a.__key;
182
+ const kb = b.__key;
183
+ for (let i = 0; i < ka.length; i++) {
184
+ if (ka[i] < kb[i]) return -1;
185
+ if (ka[i] > kb[i]) return 1;
186
+ }
187
+ // Final, stable tiebreak on original index.
188
+ return a.__idx - b.__idx;
189
+ }
190
+
191
+ // Wrap input records with stable metadata WITHOUT mutating the caller's objects.
192
+ function prepare(list, label) {
193
+ if (!Array.isArray(list)) {
194
+ throw new MatchError(`${label} must be an array of records`);
195
+ }
196
+ return list.map((rec, i) => {
197
+ if (rec == null || typeof rec !== "object") {
198
+ throw new MatchError(`${label}[${i}] is not a record object`);
199
+ }
200
+ if (typeof rec.amount !== "number" || !Number.isInteger(rec.amount)) {
201
+ throw new MatchError(`${label}[${i}].amount must be integer cents`);
202
+ }
203
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(rec.date || ""))) {
204
+ throw new MatchError(`${label}[${i}].date must be "YYYY-MM-DD"`);
205
+ }
206
+ const w = {
207
+ __idx: i,
208
+ __rec: rec,
209
+ date: rec.date,
210
+ amount: rec.amount,
211
+ memo: rec.memo == null ? "" : String(rec.memo),
212
+ used: false,
213
+ };
214
+ w.__key = sortKey(w);
215
+ return w;
216
+ });
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Confidence model
221
+ // ---------------------------------------------------------------------------
222
+
223
+ // Combine the date closeness and memo similarity into a 0..1 confidence for a
224
+ // same-amount pairing. Exact same-day with matching memo => ~1.0; a far-but-in-
225
+ // window date and unrelated memo => lower but still > a floor that keeps it
226
+ // above "unmatched". Monotonic and deterministic.
227
+ function confidenceFor(dDiff, tol, memoSim, memoWeight) {
228
+ // Date component: 1 at 0 days, linearly down to a 0.5 floor at the tolerance
229
+ // edge (in-window is never "low confidence on date alone").
230
+ const dateComp = tol === 0 ? 1 : 1 - 0.5 * (dDiff / tol);
231
+ // Blend in memo similarity with its configured weight.
232
+ const c = (1 - memoWeight) * dateComp + memoWeight * memoSim;
233
+ // Clamp and round to 4 dp so output is stable/printable.
234
+ return Math.round(Math.min(1, Math.max(0, c)) * 10000) / 10000;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Core
239
+ // ---------------------------------------------------------------------------
240
+
241
+ function reconcile(listA, listB, opts = {}) {
242
+ const cfg = { ...DEFAULTS, ...(opts || {}) };
243
+ if (cfg.amountToleranceCents !== 0) {
244
+ throw new MatchError(
245
+ "amountToleranceCents must be 0: money reconciles exactly, only DATE is fuzzy"
246
+ );
247
+ }
248
+ if (!Number.isInteger(cfg.dateToleranceDays) || cfg.dateToleranceDays < 0) {
249
+ throw new MatchError("dateToleranceDays must be a non-negative integer");
250
+ }
251
+ if (!Number.isInteger(cfg.maxSplitParts) || cfg.maxSplitParts < 1) {
252
+ throw new MatchError("maxSplitParts must be a positive integer");
253
+ }
254
+ if (
255
+ !Number.isInteger(cfg.maxSplitCandidates) ||
256
+ cfg.maxSplitCandidates < 1
257
+ ) {
258
+ throw new MatchError("maxSplitCandidates must be a positive integer");
259
+ }
260
+
261
+ const A = prepare(listA, "listA").sort(cmpKey);
262
+ const B = prepare(listB, "listB").sort(cmpKey);
263
+
264
+ const matched = [];
265
+
266
+ // -- Pass 1: EXACT (same amount AND same date). --------------------------
267
+ // Most lines reconcile here. Index B by an exact (date,amount) bucket so the
268
+ // pass is linear; consume greedily in stable sorted order so the choice among
269
+ // identical candidates is deterministic.
270
+ pairOneToOne(A, B, matched, {
271
+ tol: 0,
272
+ cfg,
273
+ kind: "exact",
274
+ sameDateOnly: true,
275
+ });
276
+
277
+ // -- Pass 2: AMOUNT + DATE WINDOW (timing differences, incl. NSF). -------
278
+ // Same magnitude, date within tolerance. An equal-and-opposite amount within
279
+ // the window is the signature of an NSF reversal and is labeled as such.
280
+ pairOneToOne(A, B, matched, {
281
+ tol: cfg.dateToleranceDays,
282
+ cfg,
283
+ kind: "amount+window",
284
+ sameDateOnly: false,
285
+ });
286
+
287
+ // -- Pass 3: SPLIT / BATCHED deposits. -----------------------------------
288
+ // One remaining record on one side whose amount equals the SUM of a small set
289
+ // of remaining records on the other side, all within the date window of the
290
+ // single side. Try A-singletons against B-subsets, then B-singletons against
291
+ // A-subsets, so both "bank batched the ledger" and "ledger batched the bank"
292
+ // are covered.
293
+ pairSplits(A, B, matched, cfg, /*aIsSingle=*/ true);
294
+ pairSplits(B, A, matched, cfg, /*aIsSingle=*/ false);
295
+
296
+ // Whatever is still unused is a genuine exception the broker must investigate.
297
+ const unmatchedA = A.filter((w) => !w.used)
298
+ .sort(cmpKey)
299
+ .map((w) => w.__rec);
300
+ const unmatchedB = B.filter((w) => !w.used)
301
+ .sort(cmpKey)
302
+ .map((w) => w.__rec);
303
+
304
+ // Sort matched deterministically by the A-side key then B-side key so output
305
+ // order is independent of pass order and input order.
306
+ matched.sort((x, y) => {
307
+ const ka = firstKey(x.__akeys);
308
+ const kb = firstKey(y.__akeys);
309
+ if (ka < kb) return -1;
310
+ if (ka > kb) return 1;
311
+ const kc = firstKey(x.__bkeys);
312
+ const kd = firstKey(y.__bkeys);
313
+ if (kc < kd) return -1;
314
+ if (kc > kd) return 1;
315
+ return 0;
316
+ });
317
+
318
+ return {
319
+ matched: matched.map(stripMeta),
320
+ unmatchedA,
321
+ unmatchedB,
322
+ };
323
+ }
324
+
325
+ function firstKey(keys) {
326
+ // keys is an array of __key arrays; compare by their min for stable ordering.
327
+ let best = null;
328
+ for (const k of keys) {
329
+ const s = k.join("");
330
+ if (best === null || s < best) best = s;
331
+ }
332
+ return best == null ? "" : best;
333
+ }
334
+
335
+ function stripMeta(m) {
336
+ return { a: m.a, b: m.b, confidence: m.confidence, kind: m.kind };
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Pass 1 & 2: one-to-one pairing
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function pairOneToOne(A, B, matched, { tol, cfg, kind, sameDateOnly }) {
344
+ // Bucket unused B by amount magnitude AND by signed amount so we can find both
345
+ // equal and equal-and-opposite (NSF) partners quickly.
346
+ const bByAmount = new Map(); // signed amount -> array of unused B (sorted)
347
+ for (const w of B) {
348
+ if (w.used) continue;
349
+ if (!bByAmount.has(w.amount)) bByAmount.set(w.amount, []);
350
+ bByAmount.get(w.amount).push(w);
351
+ }
352
+
353
+ for (const a of A) {
354
+ if (a.used) continue;
355
+
356
+ // Candidate B records: same signed amount (normal) OR equal-and-opposite
357
+ // (NSF reversal). We prefer a same-sign partner; only treat opposite-sign as
358
+ // an NSF reversal, which we label distinctly.
359
+ const sameSign = bByAmount.get(a.amount) || [];
360
+ const oppSign = a.amount !== 0 ? bByAmount.get(-a.amount) || [] : [];
361
+
362
+ let chosen = null;
363
+ let chosenIsNsf = false;
364
+ let chosenDiff = Infinity;
365
+
366
+ // Helper to scan a candidate list and pick the best (smallest date diff,
367
+ // then highest memo similarity, then stable key) within the window.
368
+ const scan = (cands, isNsf) => {
369
+ for (const b of cands) {
370
+ if (b.used) continue;
371
+ const dd = dayDiff(a.date, b.date);
372
+ if (sameDateOnly && dd !== 0) continue;
373
+ if (!sameDateOnly && dd > tol) continue;
374
+ if (chosen === null) {
375
+ chosen = b;
376
+ chosenIsNsf = isNsf;
377
+ chosenDiff = dd;
378
+ continue;
379
+ }
380
+ // Prefer non-NSF over NSF, then smaller date diff, then stable key.
381
+ const better =
382
+ (chosenIsNsf && !isNsf) ||
383
+ (chosenIsNsf === isNsf && dd < chosenDiff) ||
384
+ (chosenIsNsf === isNsf && dd === chosenDiff && cmpKey(b, chosen) < 0);
385
+ if (better) {
386
+ chosen = b;
387
+ chosenIsNsf = isNsf;
388
+ chosenDiff = dd;
389
+ }
390
+ }
391
+ };
392
+
393
+ scan(sameSign, false);
394
+ // Only consider an NSF reversal when no clean same-sign partner was found
395
+ // OR the NSF is strictly closer in date — but a same-sign exact match always
396
+ // wins. We bias toward same-sign by scanning it first and only letting NSF
397
+ // replace it when nothing same-sign qualified.
398
+ if (chosen === null) scan(oppSign, true);
399
+
400
+ if (chosen) {
401
+ a.used = true;
402
+ chosen.used = true;
403
+ const memoSim = memoSimilarity(a.memo, chosen.memo);
404
+ const conf = confidenceFor(chosenDiff, tol, memoSim, cfg.memoWeight);
405
+ matched.push({
406
+ a: a.__rec,
407
+ b: chosen.__rec,
408
+ confidence: chosenIsNsf ? Math.min(conf, 0.95) : conf,
409
+ kind: chosenIsNsf ? "nsf-reversal" : kind,
410
+ __akeys: [a.__key],
411
+ __bkeys: [chosen.__key],
412
+ });
413
+ }
414
+ }
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Pass 3: split / batched-deposit pairing
419
+ //
420
+ // For each unused single record S on the "single" side, search the unused
421
+ // records on the other side for a SUBSET whose amounts sum EXACTLY to S.amount
422
+ // and that all fall within S's date window. Deterministic subset search bounded
423
+ // by cfg.maxSplitParts, candidates pre-filtered to the window and ordered by
424
+ // stable key so the FIRST exact-sum subset found is reproducible.
425
+ // ---------------------------------------------------------------------------
426
+
427
+ function pairSplits(single, many, matched, cfg, singleIsA) {
428
+ for (const s of single) {
429
+ if (s.used) continue;
430
+ if (s.amount === 0) continue; // a zero line never needs a multi-part sum
431
+
432
+ // Eligible parts: unused, same SIGN as the single (a positive deposit is the
433
+ // sum of positive payments), and within the date window of the single.
434
+ const parts = many
435
+ .filter(
436
+ (w) =>
437
+ !w.used &&
438
+ sameSign(w.amount, s.amount) &&
439
+ dayDiff(s.date, w.date) <= cfg.dateToleranceDays
440
+ )
441
+ .sort(cmpKey);
442
+
443
+ // BREADTH guard. findSubsetSum bounds DEPTH (maxSplitParts) but C(n, k)
444
+ // still explodes when the windowed pool n is large — exactly what happens
445
+ // when dozens of same-sign lines cluster on one date. Rather than grind for
446
+ // minutes on a single reconciliation, we decline the brute-force split once
447
+ // the pool exceeds maxSplitCandidates and leave the single as an exception.
448
+ // This is deterministic (pool size is order-independent) and mirrors the
449
+ // maxSplitParts "surface it rather than guess" philosophy. We only skip the
450
+ // SPLIT search; the record still appears in the unmatched output below.
451
+ if (parts.length > cfg.maxSplitCandidates) continue;
452
+
453
+ const combo = findSubsetSum(parts, s.amount, cfg.maxSplitParts);
454
+ if (!combo) continue;
455
+
456
+ // Commit.
457
+ s.used = true;
458
+ for (const p of combo) p.used = true;
459
+
460
+ // Confidence: worst (largest) date gap among parts drives the date
461
+ // component; memo similarity averaged across parts.
462
+ let maxDiff = 0;
463
+ let memoAcc = 0;
464
+ for (const p of combo) {
465
+ maxDiff = Math.max(maxDiff, dayDiff(s.date, p.date));
466
+ memoAcc += memoSimilarity(s.memo, p.memo);
467
+ }
468
+ const memoSim = combo.length ? memoAcc / combo.length : 0;
469
+ const conf = confidenceFor(
470
+ maxDiff,
471
+ cfg.dateToleranceDays,
472
+ memoSim,
473
+ cfg.memoWeight
474
+ );
475
+
476
+ const partsRecs = combo.map((p) => p.__rec);
477
+ const partsKeys = combo.map((p) => p.__key);
478
+ matched.push({
479
+ a: singleIsA ? s.__rec : partsRecs,
480
+ b: singleIsA ? partsRecs : s.__rec,
481
+ confidence: Math.round(Math.min(conf, 0.99) * 10000) / 10000,
482
+ kind: "split",
483
+ __akeys: singleIsA ? [s.__key] : partsKeys,
484
+ __bkeys: singleIsA ? partsKeys : [s.__key],
485
+ });
486
+ }
487
+ }
488
+
489
+ function sameSign(x, y) {
490
+ return (x >= 0 && y >= 0) || (x < 0 && y < 0);
491
+ }
492
+
493
+ // Find a subset of `parts` (each {amount}) summing EXACTLY to `target`, using
494
+ // 2..maxParts elements, returning the elements or null. Deterministic: explores
495
+ // in the stable order `parts` is already sorted in and returns the first hit,
496
+ // preferring FEWER parts (shallower combinations) first.
497
+ function findSubsetSum(parts, target, maxParts) {
498
+ // We require at least 2 parts — a 1-part "split" is just a one-to-one match
499
+ // that the earlier passes already had their chance at.
500
+ const n = parts.length;
501
+ const cap = Math.min(maxParts, n);
502
+
503
+ // Try increasing subset sizes so the smallest valid combination wins.
504
+ for (let size = 2; size <= cap; size++) {
505
+ const pick = new Array(size);
506
+ const res = recurse(0, 0, 0, target);
507
+ if (res) return res;
508
+
509
+ // Depth-first choose `size` indices in increasing order summing to target.
510
+ function recurse(start, depth, sum) {
511
+ if (depth === size) {
512
+ return sum === target ? pick.slice() : null;
513
+ }
514
+ const remaining = size - depth;
515
+ for (let i = start; i <= n - remaining; i++) {
516
+ const next = sum + parts[i].amount;
517
+ // Prune: with same-sign positive parts an overshoot can never recover,
518
+ // so skip this index. `parts` is ordered by (date, amount) — not purely
519
+ // by amount — so we `continue` past this candidate rather than `break`
520
+ // out, since a smaller-amount part may still appear at a later index.
521
+ if (target >= 0 && next > target && parts[i].amount > 0) {
522
+ continue;
523
+ }
524
+ // Symmetric prune for negative targets (sum of negative parts).
525
+ if (target < 0 && next < target && parts[i].amount < 0) {
526
+ continue;
527
+ }
528
+ pick[depth] = parts[i];
529
+ const found = recurse(i + 1, depth + 1, next);
530
+ if (found) return found;
531
+ }
532
+ return null;
533
+ }
534
+ }
535
+ return null;
536
+ }
537
+
538
+ module.exports = {
539
+ reconcile,
540
+ MatchError,
541
+ DEFAULTS,
542
+ // exported for focused tests / reuse
543
+ dayDiff,
544
+ memoSimilarity,
545
+ findSubsetSum: (parts, target, maxParts = DEFAULTS.maxSplitParts) =>
546
+ findSubsetSum(
547
+ parts.map((p) => ({ amount: p.amount, __key: [], __idx: 0 })),
548
+ target,
549
+ maxParts
550
+ ),
551
+ };