Your internal records contain receipts of 120 and 180 cents. The bank reports 140 and 160 cents. No individual pair agrees, but both sides total 300. That arithmetic supports a grouped explanation. It still leaves two questions: were these the intended settlement members, and is there another grouping that also fits?
This tutorial lets operations inspect those questions and gives engineers an exact reference search. You will retain source versions, explain an independently evidenced fee, distinguish ambiguity from an unfinished search, and keep unmatched evidence visible. Every Cedar amount, event, pool and fee below is a synthetic teaching input. All arithmetic is author-derived; the API and conservative policy are implementation choices.
Establish the pool before searching
Exact Reference Match teaches normalized account-owner cash movements, immutable internal rows, externally revised evidence and independently trusted coverage heads. Those rules still apply. Both source histories must be complete through their heads before the knowledge cutoff is used. A later source withdrawal does not erase a real internal posting, and a booked reversal already carries its direction.
Here an investigator offers a candidate pool for one intended settlement group. The pool may include unused alternatives. It is a hypothesis, not a declaration that every listed row settled together. Its provenance and completeness are host obligations. A different pool or an omitted candidate can change uniqueness; the reference does not discover a globally optimal partition of an entire statement.
Each pool binds currency, inflow/outflow direction, reversal classification and the complete current revision identity of each possible member. Rows cannot appear in two pools. A changed revision must be investigated again rather than silently substituted under the same row name. Source rows outside all pools remain explicitly unassigned.
Keep the gross-to-net equation visible
For the account owner, inflow increases a cash asset. In a fee-deducted receipt, internal gross I equals external net E plus fee F. The canonical inflow comparison is therefore I = E + F. An outflow with a separately evidenced fee uses E = I + F: the external cash payment includes that fee.
The official Adyen batch-level documentation distinguishes gross, fees and net settlement. It motivates keeping those facts separate; our JSON, UTC window, fee identity and search do not implement Adyen's reports or reproduce its examples.
Open the complete grouping diagram. Internal 120 + 180 equals external 140 + 150 plus the independently evidenced 10-cent fee. Without that evidence, the 10-cent difference stays unexplained. Deriving a fee from the residual would manufacture the fact the exercise was meant to verify.
The fee has its own immutable item ID, receipt reference and availability time. A document may contain several fee items, so a document receipt is not treated as a unique fee identity. Reusing a fee item across pools rejects the request. All values share one currency and minor unit; the function performs no conversion, tolerance or hidden rounding.
Enumerate alternatives without choosing by order
With n rows on one side there are 2^n − 1 nonempty subsets. An eight-row pool therefore has 255 subsets per side and at most 65,025 subset-pair comparisons. The limit is an authored teaching bound, not a bank capacity claim. NIST's combination reference supplies the subset-counting context; the reconciliation constraints are our own.
The reference sorts stable row identities, enumerates numeric subset masks and checks every pair's total booking-date span and exact amount equation. The date span includes every selected row on both sides and is inclusive at the configured boundary. Value dates are retained as evidence and never substituted for booking dates. TypeScript keeps all money in BigInt; Python uses integers.
Canonical internal [120, 180] and external [140, 160] have just one balanced subset pair: both complete lists. Internal [300] and external [100, 200] explain a split receipt. Internal [100, 200] and external [300] explain a batch. The returned cardinality names what was actually found, including a possible one-to-one candidate.
A second witness changes the answer
Consider two different internal rows of 100 and one external row of 100. Either internal row can explain the external amount. Choosing the first delivered row would hide ambiguity. Choosing the smallest ID would merely replace delivery order with another arbitrary rule.
Two distinct subset-pair witnesses are enough to prove non-uniqueness. The reference stops there and says the candidate count is at least two. It does not pretend to know the total count. Exhaustive remains false because the rest of the search was unnecessary to establish ambiguity.
Open the search-outcome comparison. A complete search with one witness supports a review proposal. A complete search with none supports no-match within this pool. A stopped search supports neither conclusion.
A budget is a boundary on knowledge
Every examined subset pair counts against the comparison budget before date or amount gates. Exactly enough budget to finish is sufficient. If another comparison is needed, the pool is indeterminate. One candidate found earlier does not become unique just because the budget ran out.
An oversized pool also returns indeterminate. When any pool is indeterminate, this reference suppresses all request proposals while retaining diagnostic results. That prevents a partially searched request from appearing fully accepted. Completed ambiguous and no-match pools remain explicit too; a successful computation code does not mean every statement row reconciled.
The comparison order gives reproducible diagnostics; it never decides which ambiguous financial grouping wins.
Run the complete example
From the topic directory, run python examples/run_example.py. The supplied request includes both complete source exports, their independent heads, the pool identities and the policy. The useful call is:
result = many_to_many_reconcile(
request["query"], request["internal_groups"],
request["external_groups"], request["context"])
assert result["components"][0]["code"] == "UNIQUE_GROUP"
witness = result["proposals"][0]["witness"]
assert witness["internal_sum_minor"] == "300"
assert witness["external_sum_minor"] == "300"
The complete fixture download contains 68 expected results, including 27 literal equation cases written before implementation. Six behavioral suites add independent combination enumeration, delivery permutations, output detachment, fee-item reuse, aggregate proposal suppression and the complete 65,025-comparison boundary. Python and strict TypeScript must match complete JSON, including errors and nullability. Validation evidence records the actual gates.
The guided lab changes pools and the search budget using the tested TypeScript reference. Its final preview remains complete from the start; Step highlights the reported evidence and witnesses. It does not show fictitious partially accepted matches.
Carry the evidence into resolution
An equal sum is a review proposal. It does not establish counterparty intent, source authenticity, settlement finality or posting authority. The host must prevent previously accepted rows from being spent again across runs and must review accepted links after evidence changes. Do not omit history to disguise those obligations.
The historical-example decision is not useful without reusable complete point-in-time settlement records and applicable fee evidence. Continue with Suspense-Account Resolution for authorized clearing and immutable corrections, and Balance-Proof Algorithm for the independent opening-to-closing bridge. Those controls answer questions a subset equation cannot settle.
References4 primary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
R01 — Batch-level reconciliation
- Organization: Adyen; official product documentation, undated current page.
- URL: https://docs.adyen.com/reporting/settlement-reconciliation/batch-level/
- Accessed: 25 October 2026.
- Source type: primary processor reporting documentation.
- Supports: batch reports distinguish gross, fee and net amounts; a batch can span dates/types/currencies.
- Limitations: our bounded candidate pools, fee identities and search are authored, without Adyen report/parser conformance. No published transaction table is reused.
R02 — Combination
- Organization: NIST Software and Systems Division; Paul E. Black, DADS; entry modified 2 March 2015.
- URL: https://xlinux.nist.gov/dads/HTML/combination.html
- Accessed: 25 October 2026.
- Source type: primary government algorithm reference.
- Supports: combinations select a fixed-size subset without order; counting context.
- Limitations: the 8-row cap, 65025 pair bound and conservative reconciliation policy are separately authored derivations/choices.
R03 — Backtracking
- Organization: NIST Software and Systems Division; Paul E. Black, DADS; entry modified 17 May 2021.
- URL: https://xlinux.nist.gov/dads/HTML/backtrack.html
- Accessed: 25 October 2026.
- Source type: primary government algorithm reference.
- Supports: systematic alternatives/search-tree terminology.
- Limitations: implementation uses direct bounded bitmask enumeration, without an optimized backtracking solver or runtime claim.
R04 — Frozen authored contract and independent examples
- Source type: synthetic teaching evidence and authored API, 25 October 2026.
- Artifacts: examples/DATA-CONTRACT.md, datasets/independent-equations.json, datasets/cases.json and datasets/source-selection-cases.json.
- Supports: exact money, source chronology, endpoints, statuses, fees and independent expected examples.
- Limitations: source receipt strings do not authenticate evidence; amount equality does not establish settlement or authorize posting. Historical example not useful without reusable verified source history.
Full dependency-light reference implementations in both supported languages.
/** Exact bounded candidate-pool proposals. Never accepts a link or posts money. */
import {selectReconciliationRows} from './source-selection.js';
type Json = any;
const COMMON = 'schema_version ledger_id entity_id reconciliation_account_id period_start period_end knowledge_cutoff reference_policy'.split(' ');
const COMPONENT = 'component_id currency direction is_reversal internal_row_refs external_row_refs fee'.split(' ');
const REF = 'source_id row_id revision sequence source_version_ref'.split(' ');
class Invalid extends Error {}
function requireValue(ok: boolean, code: string): asserts ok {
if (!ok) throw new Invalid(code);
}
function exact(value: Json, keys: string[]): void {
requireValue(value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value,key)), 'INVALID_SCHEMA');
}
function full(regex: RegExp, value: Json): boolean {
if (typeof value !== 'string') return false;
const match = regex.exec(value);
return match !== null && match[0].length === value.length;
}
function identifier(value: Json): void {
requireValue(full(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/,value), 'INVALID_IDENTIFIER');
}
function positive(value: Json): bigint {
requireValue(full(/^[1-9][0-9]{0,29}$/,value), 'INVALID_AMOUNT');
return BigInt(value);
}
function day(value: string): number {
requireValue(full(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/,value), 'INVALID_TIMESTAMP');
const year=Number(value.slice(0,4)),month=Number(value.slice(5,7)),date=Number(value.slice(8,10));
const leap=year%4===0&&(year%100!==0||year%400===0);
const lengths=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31];
requireValue(year>=1&&month>=1&&month<=12&&date>=1&&date<=lengths[month-1], 'INVALID_TIMESTAMP');
return Date.parse(value+'T00:00:00.000Z')/86400000;
}
function timestamp(value: Json): void {
requireValue(full(/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/,value), 'INVALID_TIMESTAMP');
day(value.slice(0,10));
requireValue(Number(value.slice(11,13))<24&&Number(value.slice(14,16))<60&&Number(value.slice(17,19))<60,'INVALID_TIMESTAMP');
}
function refKey(ref: Json): string { return JSON.stringify(REF.map(key=>ref[key])); }
function compareRefs(left: Json, right: Json): number {
for (const key of REF) {
if (left[key]===right[key]) continue;
if (key==='revision'||key==='sequence') return BigInt(left[key])<BigInt(right[key])?-1:1;
return left[key]<right[key]?-1:1;
}
return 0;
}
function validateRef(ref: Json): void {
exact(ref,REF);
for (const key of ['source_id','row_id','source_version_ref']) identifier(ref[key]);
positive(ref.revision); positive(ref.sequence);
}
function empty(status: string, code: string): Json {
return {status,code,coverage:null,selected:null,components:[],proposals:[],unassigned:[]};
}
function validateWrapper(query: Json): void {
exact(query,[...COMMON,'policy','components']);
const policy=query.policy;
exact(policy,['algorithm','max_day_span','comparison_budget']);
requireValue(policy.algorithm==='bounded-subset-pool-v1','UNSUPPORTED_ALGORITHM');
requireValue(typeof policy.max_day_span==='string'&&['0','1','2','3','4','5','6','7'].includes(policy.max_day_span),'INVALID_POLICY');
requireValue(positive(policy.comparison_budget)<=100000n,'INVALID_POLICY');
requireValue(Array.isArray(query.components)&&query.components.length<=16,'INVALID_COMPONENTS');
const ids=new Set<string>(),fees=new Set<string>();
const used: Record<string,Set<string>>={internal:new Set(),external:new Set()};
for (const component of query.components) {
exact(component,COMPONENT); identifier(component.component_id);
requireValue(!ids.has(component.component_id),'DUPLICATE_COMPONENT'); ids.add(component.component_id);
requireValue(['USD','EUR'].includes(component.currency),'UNSUPPORTED_CURRENCY');
requireValue(['inflow','outflow'].includes(component.direction),'INVALID_DIRECTION');
requireValue(typeof component.is_reversal==='boolean','INVALID_REVERSAL_FLAG');
for (const side of ['internal','external']) {
const refs=component[side+'_row_refs'],local=new Set<string>();
requireValue(Array.isArray(refs)&&refs.length>=1&&refs.length<=128,'INVALID_COMPONENTS');
for (const ref of refs) {
validateRef(ref); const key=refKey(ref);
requireValue(!local.has(key),'DUPLICATE_COMPONENT_ROW');
requireValue(!used[side].has(key),'ROW_REUSED_ACROSS_COMPONENTS');
local.add(key); used[side].add(key);
}
}
if (component.fee!==null) {
const fee=component.fee;
exact(fee,['fee_id','amount_minor','available_at','receipt_ref']);
identifier(fee.fee_id); identifier(fee.receipt_ref); positive(fee.amount_minor); timestamp(fee.available_at);
requireValue(!fees.has(fee.fee_id),'FEE_REUSED_ACROSS_COMPONENTS'); fees.add(fee.fee_id);
}
}
}
type Subset={rows: Json[], sum: bigint, first: number, last: number};
function subsets(rows: Json[]): Subset[] {
const values: Subset[]=[];
for (let mask=1; mask<2**rows.length; mask++) {
const chosen=rows.filter((_,index)=>(mask&(1<<index))!==0);
const days=chosen.map(row=>day(row.movement.booking_date));
values.push({rows:chosen,sum:chosen.reduce((total,row)=>total+BigInt(row.movement.amount_minor),0n),
first:Math.min(...days),last:Math.max(...days)});
}
return values;
}
function diagnostic(componentId: string,status: string,code: string,comparisons=0,exhaustive=false,
witnesses: Json[]=[],unusedInternal: Json[]=[],unusedExternal: Json[]=[]): Json {
return {component_id:componentId,status,code,comparisons:String(comparisons),exhaustive,
candidate_count_lower_bound:String(witnesses.length),witnesses,
unused_internal_row_refs:unusedInternal,unused_external_row_refs:unusedExternal};
}
function search(component: Json, internal: Json[], external: Json[], policy: Json): Json {
const fee=component.fee===null?0n:BigInt(component.fee.amount_minor),witnesses: Json[]=[];
const budget=Number(policy.comparison_budget),spanLimit=Number(policy.max_day_span);
let comparisons=0;
for (const left of subsets(internal)) {
for (const right of subsets(external)) {
if (comparisons===budget) return diagnostic(component.component_id,'indeterminate','SEARCH_LIMIT',comparisons,false,witnesses);
comparisons++;
const span=Math.max(left.last,right.last)-Math.min(left.first,right.first);
const equal=component.direction==='inflow'?left.sum===right.sum+fee:right.sum===left.sum+fee;
if (span>spanLimit||!equal) continue;
const cardinality=(left.rows.length===1?'one':'many')+'-to-'+(right.rows.length===1?'one':'many');
witnesses.push({internal_row_refs:left.rows.map(row=>row.row_ref),external_row_refs:right.rows.map(row=>row.row_ref),
internal_sum_minor:String(left.sum),external_sum_minor:String(right.sum),fee_minor:String(fee),
booking_date_span_days:span,cardinality});
if (witnesses.length===2) return diagnostic(component.component_id,'ambiguous','MULTIPLE_GROUPINGS',comparisons,false,witnesses);
}
}
const used: Record<string,Set<string>>={internal:new Set(),external:new Set()};
if (witnesses.length) for (const side of ['internal','external'])
for (const ref of witnesses[0][side+'_row_refs']) used[side].add(refKey(ref));
return diagnostic(component.component_id,witnesses.length?'unique':'no-match',witnesses.length?'UNIQUE_GROUP':'NO_GROUP_MATCH',
comparisons,true,witnesses,internal.filter(row=>!used.internal.has(refKey(row.row_ref))).map(row=>row.row_ref),
external.filter(row=>!used.external.has(refKey(row.row_ref))).map(row=>row.row_ref));
}
export function manyToManyReconcile(query: Json, internalGroups: Json, externalGroups: Json, context: Json): Json {
try {
validateWrapper(query);
const common: Json=Object.fromEntries(COMMON.map(key=>[key,query[key]]));
const source=selectReconciliationRows(common,internalGroups,externalGroups,context,'d30-many-reconciliation-v1');
if (source.status!=='ok') return empty(source.status,source.code);
const selected=source.selected,maps: Record<string,Map<string,Json>>={};
for (const side of ['internal','external']) maps[side]=new Map(selected[side].map((row: Json)=>[refKey(row.row_ref),row]));
const components=[...query.components].sort((a,b)=>a.component_id<b.component_id?-1:a.component_id>b.component_id?1:0);
for (const component of components) {
if (component.fee!==null) requireValue(component.fee.available_at<=query.knowledge_cutoff,'FUTURE_FEE');
for (const side of ['internal','external']) for (const ref of component[side+'_row_refs']) {
const row=maps[side].get(refKey(ref));
if (row!==undefined) requireValue(['currency','direction','is_reversal'].every(key=>row.movement[key]===component[key]),'COMPONENT_SCOPE_MISMATCH');
}
}
const results: Json[]=[],proposals: Json[]=[];
const used: Record<string,Set<string>>={internal:new Set(),external:new Set()};
for (const component of components) {
const rows: Record<string,Json[]>={}; let missing=false;
for (const side of ['internal','external']) {
const refs=[...component[side+'_row_refs']].sort(compareRefs);
for (const ref of refs) used[side].add(refKey(ref));
missing=missing||refs.some(ref=>!maps[side].has(refKey(ref)));
rows[side]=refs.map(ref=>maps[side].get(refKey(ref))).filter(row=>row!==undefined);
}
let result: Json;
if (missing) result=diagnostic(component.component_id,'indeterminate','COMPONENT_EVIDENCE_UNAVAILABLE');
else if (rows.internal.length>8||rows.external.length>8) result=diagnostic(component.component_id,'indeterminate','COMPONENT_LIMIT');
else result=search(component,rows.internal,rows.external,query.policy);
results.push(result);
if (result.status==='unique') proposals.push({component_id:component.component_id,status:'review-proposal',witness:result.witnesses[0]});
}
const incomplete=results.some(result=>result.status==='indeterminate'),unassigned: Json[]=[];
for (const side of ['internal','external']) for (const row of selected[side])
if (!used[side].has(refKey(row.row_ref))) unassigned.push({side,row_ref:row.row_ref,reason:'OUTSIDE_CANDIDATE_POOLS'});
// All money is already strings; copy plain JSON values to separate audit branches.
return JSON.parse(JSON.stringify({status:incomplete?'indeterminate':'ok',code:incomplete?'GROUP_SEARCH_INDETERMINATE':'GROUP_SEARCH_COMPLETE',
coverage:source.coverage,selected,components:results,proposals:incomplete?[]:proposals,unassigned}));
} catch (error) {
if (error instanceof Invalid) return empty('rejected',error.message);
throw error;
}
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
