A useful reconciliation screen tells an operator why two movements might belong together, what competing evidence exists and which facts prevent a proposal. A green score by itself does not provide that explanation. Two payments can have the same amount and almost the same description while referring to different obligations.
This tutorial builds a conservative review algorithm. It first protects financial meaning with exact gates, then computes a transparent memo/date score, then asks whether each side has a unique best partner with enough separation from its runner-up. The result is an auditable review proposal. It does not accept a match or create a ledger entry.
By the end, you can calculate the exact scores and margins, implement the same contract in Python and TypeScript, explain unresolved rows and verify that future evidence has not changed an earlier knowledge-cutoff comparison. All Cedar records and amounts below are synthetic teaching data.
Read Exact Reference Match first for reference namespaces, independent source heads and revision selection. A reference such as an instruction ID means something different from a servicer reference even when the bytes happen to resemble each other. Double-entry Posting Engine provides the separate accounting rules needed when an authorized resolution eventually proposes a journal.
Start with the evidence boundary
Our inputs are complete normalized histories for an internal source and an external source, plus trusted independent heads describing their coverage. The function does not parse arbitrary bank files or authenticate those heads. A host adapter must establish scope, source identity and provider conventions before calling it.
The normalized direction is inflow or outflow from the bank-asset owner's perspective. It is not a customer deposit account's normal-side sign. A statement adapter can map a credit entry to an inflow and a debit entry to an outflow. If a movement is flagged as a reversal, its normalized direction already describes that new movement; do not flip the sign again. External evidence withdrawal is a different event: it changes which source record is active, rather than creating an economic reversal.
Booking date is the comparison date in this profile. Value date remains evidence but does not replace booking date. These distinctions appear in provider-specific camt documentation, including the BIL implementation guidelines. An adapter must bind its actual provider/version rather than treating this teaching normalization as a universal file parser.
Validate the whole supplied histories against the independent heads before selecting a knowledge cutoff. Then retain complete receipt groups with available_at ≤ K, choose each logical row's latest visible revision, remove known withdrawals and filter active movements into the half-open booking-date window. An external revision received tomorrow must not supply today's memo or amount. A missing future group can still make source coverage indeterminate: excluding its comparison features does not authorize pretending the complete history was supplied.
For example, synthetic external row B has amount 2400 in revision 1. Revision 2 changes it to 2401 at 16:00 on 24 October. At noon, compare revision 1. At receipt equality, compare revision 2. A withdrawal at 17:00 removes B from the active set. Keep all three source events in the input at every cutoff.
Financial gates come before text
For every active internal/external pair, require equal currency, direction, exact positive minor-unit amount and reversal classification. Scope is already bound by the global source validation. There is no FX conversion, amount tolerance, fee netting or split-payment inference.
Strong references add a contradiction gate. A usable reference has a declared namespace and original printable-ASCII bytes. If both references are usable, their namespaces agree and their bytes differ, the pair receives REFERENCE_CONFLICT. Similar descriptions cannot override that contradiction. Different namespaces are noncomparable. Null, empty, ASCII-spaces-only and the exact uppercase sentinel NOTPROVIDED mean missing under this explicit profile; lowercase notprovided remains usable. Do not trim, case-fold or remove punctuation from strong reference bytes.
This conservative decision can withhold a real pair when a reference contains an error. That is a review issue, not evidence that fuzzy text should silently overrule the reference. Equal usable references remain eligible here, but the function does not know whether an earlier exact-matching workflow has already accepted that evidence.
Finally, require the absolute booking-day gap to be at most the explicit window W, between zero and seven days. Perfect text with a wrong amount or an out-of-window date receives no similarity score. The comparison audit reports the financial gate reasons instead.
Open the full-size gate and score diagram.
Notice the order: eligibility, exact score, then ambiguity clearance. These are three different decisions.
A reproducible score in points
The text profile is deliberately narrow. Accept printable ASCII characters, lowercase A–Z, extract maximal [a-z0-9]+ tokens, deduplicate them into sets and sort the result. Token order and repeated words do not change the set score. There is no stemming, stop-word removal, transliteration or semantic model.
A structurally valid description containing Arabic, accented text or emoji is marked unsupported for this fuzzy profile. Do not delete those characters and match on the remaining digits or English fragment. Shared malformed controls or lone surrogates reject the request. Empty token sets are not comparable; two empty memos never receive a score of 100.
For nonempty token sets U and V, use Jaccard overlap. Its set definition comes from the author text of Introduction to Information Retrieval; the word-token policy here is our own bounded choice.
| Symbol | Meaning |
|---|---|
| U, V | Nonempty deduplicated memo token sets |
| Δ | External booking date minus internal booking date, in civil days |
| W | Explicit maximum absolute gap, 0–7 days |
| J | Exact token-set overlap, between 0 and 1 |
| D | Exact date component after the gap gate |
| S | Comparison score in points, between 0 and 100 |
The fixed 80/20 weights, W, threshold T and margin M are policy choices. They have not been calibrated against labeled banking records. S is not a match probability. A score of 80 does not mean an 80% chance that two movements represent the same obligation.
With W = 2 and identical tokens, booking gaps of zero, one and two days score 100, 280/3 and 260/3 points. A third day fails the date gate. With W = 0, only the same day is admitted and D is 1; the formula never divides by zero.
For a same-day pair with U = {cedar, office, rent, october} and V = {cedar, office, rent}, J is 3/4. The score is 80 × 3/4 + 20 = 80 points. It passes an inclusive threshold of 80, provided the later ambiguity rules also pass.
Both implementations retain reduced rational fractions such as {"numerator":"280","denominator":"3"}. Cross multiplication compares scores and margins. Display decimals help reading but never decide equality or a tie.
A high score still needs both neighborhoods
Each row may have several plausible partners. Rank every financially/date-gated, text-comparable pair in the original graph, including pairs below T. A proposal requires all of the following:
- Its exact score is at least T.
- It is the unique highest-scoring incident pair for the internal row.
- It is the unique highest-scoring incident pair for the external row.
- On both endpoints, best minus runner-up is at least M.
Threshold and margin equality pass. With no runner-up, the margin is null and unopposed, rather than an invented score of zero. A tied best always fails uniqueness, even when M is zero. IDs sort the audit; they never pick a winner.
Keeping below-threshold rivals is essential. In a synthetic same-amount example, an internal memo has four tokens. Its top external candidate shares three of those tokens on the same day and scores 80. A rival shares all four tokens but has one extra token and a one-day booking gap:
The top-minus-runner margin is 80 − 232/3 = 8/3 points. At M = 10, hold the top pair for review. Discarding the rival merely because it is below T = 80 would manufacture an unopposed best. At M = 2, the same exact margin passes, assuming the external endpoint is also uniquely best and sufficiently separated.
Work the full two-by-two graph
In this separate synthetic graph, every movement is a USD outflow of 2400 minor units on 20 October 2026 with no usable reference. A1 has tokens a through h, A2 a through g, B7 a through g and B6 a through f. Use W = 2, T = 80 and M = 10.
| Pair | Score points | Explanation |
|---|---|---|
| A1 / B7 | 90 | B7 prefers A2, so the pair is not mutually best |
| A1 / B6 | 80 | A1 prefers B7, so this pair is not mutually best |
| A2 / B7 | 100 | Unique best on both sides with sufficient margins |
| A2 / B6 | 620/7 | A2 prefers B7, so the pair is not mutually best |
For A2, the margin is 100 − 620/7 = 80/7, greater than 10. For B7, it is 100 − 90 = 10, exactly the required margin. Propose only A2/B7.
Open the full-size mutual-best diagram.
Do not remove A2/B7 and then promote A1/B6. That would be iterative peeling under a different contract. Our decisions use the original graph once, so delivery order cannot change the result. A global assignment optimizer could seek more pairs or a larger total score; that objective does not replace the ambiguity policy taught here.
Implement the contract, then expose the audit
The public functions take four arguments: query, internal groups, external groups and context. The query carries an explicit policy and the context contains exactly two source heads. There is no claim-registry extension. The strict data contract lists every key, timestamp rule, wire type and failure envelope.
The self-contained Python implementation and TypeScript implementation embed the same pinned source selector as Exact Reference Match. They have no sibling runtime imports. The wrapper validates its own schema and policy before projecting the common query into that selector.
validate strict query and explicit policy
validate complete histories against independent heads
select latest visible active revisions in the booking window
build every gated, text-comparable edge with an exact score
rank both original neighborhoods, including below-threshold rivals
propose only mutual unique best edges with both exact margins
return selected evidence, coverage, every comparison and row decision
Successful output contains coverage, selected, proposals, comparisons and row_decisions, alongside status/code. Every active row receives a decision, including unsupported text, no eligible candidate, below threshold and review ambiguity. A hard gate withholds score features. A malformed or incomplete source returns no partial graph.
The portable profile permits at most 128 selected active rows per side, yielding at most 16,384 pair comparisons. Complete source histories separately retain bounded group/event limits. These are explicit resource choices, not banking capacity or throughput claims. The bounded all-pairs graph is easier to inspect than an opaque candidate-pruning method that might hide a relevant runner-up.
Explore the actual reference in the lab
The guided fuzzy matching lab retains 24 initial source events, 12 active rows per side and all 144 comparisons. The focus changes which row you inspect; it does not remove its competitors from computation. The full audit remains available.
Start with the canonical graph, then choose the below-threshold rival. Change M from 10 to 2 and inspect why A1's top proposal changes. Choose the tied-best scenario and set M to zero: the tie still remains unresolved. Compare the future revision with receipt equality, observing that the complete source head validates through the later event while the selected revision changes only at its receipt.
The failure scenario declares a source head with an event that is absent from the history. It returns an indeterminate result and withholds the entire candidate graph. Back, Step, Reset and keyboard controls provide deterministic observation; reduced-motion Play advances one row without starting a timer. Static diagrams and the worked arithmetic preserve the lesson without scripts.
Verify behavior and respect the acceptance boundary
The before-code oracle contains 27 realized token/date/reference graphs and three revision-cutoff cases. Additional regressions cover strict policies, malformed descriptions, resource bounds and empty evidence. The Python and TypeScript suites agree on 164 complete outputs: 59 fuzzy cases and 105 independently authored shared-selector cases. Checks cover exact fractions, proposal identities, full selection/coverage, one-row-once, delivery permutations, detached views and future-feature absence. Browser validation exercises the full lab at phone, tablet and desktop widths. Read the validation evidence for commands, results and limitations.
This is functional correctness evidence for the declared synthetic contract. It does not establish precision, recall, calibration or operational savings. The original Fellegi–Sunter record-linkage paper is useful background for separating links, nonlinks and review, but this weighted score does not implement its likelihood-ratio model. PostgreSQL trigram similarity is also a different method from our token-set score.
A historical match demonstration is not useful here without licensed paired records, point-in-time revision receipts, declared reference namespaces and adjudicated truth. Those would be prerequisites for an empirical study, not details to invent around synthetic Cedar examples.
One-row-once holds within this proposal run. Every selected row is evaluated; the function does not certify that an earlier run has not already accepted it. Before durable acceptance, the host's accepted-resolution registry must veto or review reused evidence and revised source identities. Do not omit source history to simulate exclusions. No function here reserves rows concurrently, resolves suspense, proves settlement or commits a journal.
The achieved capability is an explainable, reproducible review graph with explicit financial gates and preserved ambiguity. Many-to-Many Reconciliation follows by handling explicit groups beyond this one-to-one profile. Suspense-Account Resolution separately organizes unresolved evidence and its later resolution under an identity and acceptance contract.
ReferencesPrimary 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.
Accessed: 25 October 2026. Cedar Bank, CB-A and all paired rows in this package are fictional teaching data.
Supports: the table identifies each source's exact role, edition/provider setting and supported definition or convention.
Limitations: no source below validates the chosen 80/20 weights, threshold, margin, match quality or capacity. The role/limit column records source-specific boundaries.
| Source | Version / setting | Role and boundary |
|---|---|---|
| Manning, Raghavan and Schütze, Introduction to Information Retrieval | Cambridge University Press 2008; author-hosted 2009 PDF, §3.3, p.61 | Primary author text for Jaccard set intersection divided by union. Their spelling/k-gram context differs from our word-token policy. |
| Fellegi and Sunter, A Theory for Record Linkage | JASA 64(328), 1969, pp.1183–1210; DOI 10.1080/01621459.1969.10501049 | Original paper motivates separating links, nonlinks and review. This package does not implement its likelihood-ratio theory or estimate match probabilities. |
| PostgreSQL pg_trgm | PostgreSQL 18 official documentation | Comparison only: trigram similarity and its operators are a different method. No pg_trgm execution or performance result is claimed. |
| BIL camt.053 implementation guidelines | camt.053.001.02, v2.1, 8 August 2023, reversal indicator and booking/value date sections | Provider-specific statement conventions distinguish credit/debit direction, reversal indicator and dates. A normalized reversal movement already has a direction; do not flip it again. This is not a universal camt parser. |
| Bank of America Bank-to-Customer Statement reference guide | Official eight-page provider guide; no immutable publication version identified | Supports distinguishing statement/account/entry references, booking date and value date. Does not certify an end-to-end identifier is unique across arbitrary bank records. |
Historical example decision: not useful for this mechanics tutorial. A defensible empirical demonstration would require licensed paired internal/external records, available-at revision chronology, a declared reference namespace, adjudicated match truth and redistribution rights. Synthetic examples cannot establish precision, recall, calibration or operational savings.
Implementation choices: ASCII token sets, fixed point weights, gates, reference-sentinel policy, limits and mutual-best margins. Author-derived results: exact Fraction/BigInt rational arithmetic and the worked graph. Provider conventions are examples to be mapped explicitly by an adapter, not jurisdiction-wide legal requirements.
Full dependency-light reference implementations in both supported languages.
/** Conservative exact proposals; no parser, source authentication or posting. */
type Obj=Record<string,any>;
const QUERY=['schema_version','ledger_id','entity_id','reconciliation_account_id','period_start','period_end','knowledge_cutoff','reference_policy'];
const HEAD=['schema_version','source_id','side','ledger_id','entity_id','reconciliation_account_id','coverage_start','coverage_end','observed_at','head_sequence','receipt_ref'];
const GROUP=['schema_version','group_id','source_id','available_at','events'];
const EVENT=['sequence','row_id','revision','supersedes_revision','source_version_ref','action','movement'];
const MOVEMENT=['booking_status','booking_date','value_date','currency','direction','amount_minor','reference','description','is_reversal'];
const IDENT=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const POSITIVE=/^[1-9][0-9]{0,29}$/;
const NONNEGATIVE=/^(0|[1-9][0-9]{0,29})$/;
const DATE=/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
const STAMP=/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
const INDETERMINATE=new Set(['SOURCE_SEQUENCE_GAP','INCOMPLETE_SOURCE','KNOWLEDGE_BEYOND_SOURCE','COVERAGE_WINDOW_MISMATCH']);
export class ReconciliationError extends Error {}
function need(condition:any,code:string):asserts condition {if(!condition)throw new ReconciliationError(code)}
const object=(v:any):v is Obj=>v!==null&&typeof v==='object'&&!Array.isArray(v)&&[Object.prototype,null].includes(Object.getPrototypeOf(v));
const exact=(v:any,keys:string[]):v is Obj=>object(v)&&Object.keys(v).length===keys.length&&keys.every(k=>Object.hasOwn(v,k));
const full=(r:RegExp,s:string)=>r.exec(s)?.[0]===s;
const identifier=(v:any)=>typeof v==='string'&&full(IDENT,v);
const positive=(v:any)=>typeof v==='string'&&full(POSITIVE,v);
const nonnegative=(v:any)=>typeof v==='string'&&full(NONNEGATIVE,v);
const compare=(a:string,b:string)=>a<b?-1:a>b?1:0;
const compareInteger=(a:string,b:string)=>BigInt(a)<BigInt(b)?-1:BigInt(a)>BigInt(b)?1:0;
function civil(v:any):number|null {
if(typeof v!=='string'||!full(DATE,v)||v.slice(0,4)==='0000')return null;
const d=new Date(v+'T00:00:00.000Z');
return Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===v?d.getTime():null;
}
function timestamp(v:any):boolean {
if(typeof v!=='string'||!full(STAMP,v)||v.slice(0,4)==='0000')return false;
const d=new Date(v);return Number.isFinite(d.getTime())&&d.toISOString()===v;
}
function same(a:any,b:any):boolean {
if(Array.isArray(a))return Array.isArray(b)&&a.length===b.length&&a.every((v,i)=>same(v,b[i]));
if(object(a))return object(b)&&Object.keys(a).length===Object.keys(b).length&&Object.keys(a).every(k=>Object.hasOwn(b,k)&&same(a[k],b[k]));
return a===b;
}
function stable(v:any):string {
if(Array.isArray(v))return '['+v.map(stable).join(',')+']';
if(object(v))return '{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+stable(v[k])).join(',')+'}';
return JSON.stringify(v);
}
function validateMovement(m:any,available:string){
need(exact(m,MOVEMENT),'INVALID_SCHEMA');
need(m.booking_status==='booked','UNSUPPORTED_BOOKING_STATUS');
need(civil(m.booking_date)!==null&&civil(m.value_date)!==null,'INVALID_DATE');
need(m.booking_date<=available.slice(0,10),'FUTURE_BOOKING_DATE');
need(typeof m.currency==='string'&&['USD','EUR'].includes(m.currency),'UNSUPPORTED_CURRENCY');
need(typeof m.direction==='string'&&['inflow','outflow'].includes(m.direction),'INVALID_DIRECTION');
need(positive(m.amount_minor),'INVALID_AMOUNT');need(typeof m.is_reversal==='boolean','INVALID_REVERSAL_FLAG');
need(typeof m.description==='string'&&[...m.description].length<=240&&[...m.description].every(c=>{const n=c.codePointAt(0)!;return !(n<32||(n>=127&&n<=159)||(n>=0xd800&&n<=0xdfff))}),'INVALID_DESCRIPTION');
if(m.reference!==null){const r=m.reference;need(exact(r,['namespace','value']),'INVALID_REFERENCE');need(identifier(r.namespace),'INVALID_REFERENCE');need(typeof r.value==='string'&&r.value.length<=140&&[...r.value].every(c=>c.codePointAt(0)!>=32&&c.codePointAt(0)!<=126),'INVALID_REFERENCE')}
}
function validateEvent(e:any,available:string){
need(exact(e,EVENT),'INVALID_SCHEMA');need(positive(e.sequence),'INVALID_SEQUENCE');
need(identifier(e.row_id)&&identifier(e.source_version_ref),'INVALID_IDENTIFIER');
need(positive(e.revision),'INVALID_REVISION');
need(e.supersedes_revision===null||positive(e.supersedes_revision),'INVALID_REVISION');
need(typeof e.action==='string'&&['upsert','withdraw'].includes(e.action),'INVALID_REVISION_ACTION');
if(e.action==='upsert')validateMovement(e.movement,available);else need(e.movement===null,'INVALID_REVISION_ACTION');
}
function groupErrors(groups:any[],head:Obj){
const errors:string[]=[];
for(const g of groups){try{
need(exact(g,GROUP),'INVALID_GROUP');need(identifier(g.group_id)&&identifier(g.source_id),'INVALID_IDENTIFIER');
need(g.schema_version==='d30-reconciliation-group-v1','UNSUPPORTED_SCHEMA');need(g.source_id===head.source_id,'SOURCE_MISMATCH');
need(timestamp(g.available_at),'INVALID_TIMESTAMP');need(g.available_at<=head.observed_at,'CLOCK_ORDER_VIOLATION');
need(Array.isArray(g.events)&&g.events.length>=1&&g.events.length<=32,'INVALID_GROUP');
}catch(error){if(error instanceof ReconciliationError)errors.push(error.message);else throw error}}
if(errors.length)throw new ReconciliationError(errors.sort()[0]);
}
function rowReference(head:Obj,event:Obj):Obj {
return {source_id:head.source_id,row_id:event.row_id,revision:event.revision,sequence:event.sequence,source_version_ref:event.source_version_ref};
}
function selectedRow(head:Obj,group:Obj,event:Obj):Obj {
return {row_ref:rowReference(head,event),group_id:group.group_id,available_at:group.available_at,movement:structuredClone(event.movement)};
}
function selectSource(query:Obj,groups:any,head:Obj):[Obj[],Obj[],string,Obj]{
need(Array.isArray(groups)&&groups.length<=4096,'INVALID_GROUP');groupErrors(groups,head);
need(groups.reduce((n,g)=>n+g.events.length,0)<=4096,'INVALID_GROUP');
const errors:string[]=[];
for(const g of groups)for(const event of g.events){try{validateEvent(event,g.available_at)}catch(error){if(error instanceof ReconciliationError)errors.push(error.message);else throw error}}
if(errors.length)throw new ReconciliationError(errors.sort()[0]);
const grouped=new Map<string,Obj[]>();
for(const g of groups){const canonical={...g,events:[...g.events].sort((a,b)=>compareInteger(a.sequence,b.sequence)||compare(stable(a),stable(b)))};const list=grouped.get(g.group_id)??[];list.push(canonical);grouped.set(g.group_id,list)}
need(![...grouped.values()].some(items=>items.some(g=>!same(g,items[0]))),'GROUP_ID_CONFLICT');
need(![...grouped.values()].some(items=>items.length>1),'DUPLICATE_GROUP');
const identities=new Map<string,Obj[]>();
for(const g of groups)for(const e of g.events){const key=JSON.stringify([e.row_id,e.revision]),list=identities.get(key)??[];list.push(e);identities.set(key,list)}
need(![...identities.values()].some(items=>items.some(e=>!same(e,items[0]))),'EVENT_ID_CONFLICT');
need(![...identities.values()].some(items=>items.length>1),'DUPLICATE_EVENT');
const events:Obj[]=groups.flatMap(g=>g.events);
need(new Set(events.map(e=>e.sequence)).size===events.length,'SEQUENCE_ID_CONFLICT');
for(const g of groups)need(new Set(g.events.map((e:Obj)=>e.row_id)).size===g.events.length,'DUPLICATE_ROW_IN_GROUP');
const sequences=events.map(e=>BigInt(e.sequence)).sort((a,b)=>a<b?-1:a>b?1:0),headSequence=BigInt(head.head_sequence);
need(sequences.length===0||sequences.at(-1)!<=headSequence,'HEAD_SEQUENCE_MISMATCH');
need(sequences.every((n,i)=>n===BigInt(i+1)),'SOURCE_SEQUENCE_GAP');
need(BigInt(sequences.length)===headSequence,'INCOMPLETE_SOURCE');
const canonical=[...grouped.values()].map(items=>items[0]).sort((a,b)=>compareInteger(a.events[0].sequence,b.events[0].sequence));
for(const g of canonical)need(BigInt(g.events.at(-1).sequence)-BigInt(g.events[0].sequence)+1n===BigInt(g.events.length),'GROUP_INTERVAL_CONFLICT');
let lastAvailable:string|null=null,visibleThrough='0';
const latestAll=new Map<string,[Obj,Obj]>(),latestVisible=new Map<string,[Obj,Obj]>();
for(const g of canonical){
need(lastAvailable===null||g.available_at>=lastAvailable,'CLOCK_ORDER_VIOLATION');lastAvailable=g.available_at;
for(const e of g.events){
const previous=latestAll.get(e.row_id);
if(head.side==='internal')need(e.revision==='1'&&e.supersedes_revision===null&&e.action==='upsert'&&previous===undefined,'INTERNAL_ROW_IMMUTABLE');
else if(previous===undefined){need(e.revision==='1'&&e.supersedes_revision===null,'INVALID_REVISION');need(e.action==='upsert','INVALID_REVISION_ACTION')}
else{const prior=previous[1];need(BigInt(e.revision)===BigInt(prior.revision)+1n&&e.supersedes_revision===prior.revision,'INVALID_REVISION');if(e.action==='withdraw')need(prior.action==='upsert','INVALID_REVISION_ACTION')}
latestAll.set(e.row_id,[g,e]);if(g.available_at<=query.knowledge_cutoff)latestVisible.set(e.row_id,[g,e]);
}
if(g.available_at<=query.knowledge_cutoff)visibleThrough=g.events.at(-1).sequence;
}
const active:Obj[]=[],withdrawn:Obj[]=[];let outside=0;
for(const [rowId,[g,e]]of [...latestVisible.entries()].sort((a,b)=>compare(a[0],b[0]))){
const row=selectedRow(head,g,e);
if(e.action==='withdraw'){delete row.movement;withdrawn.push(row)}
else if(e.movement.booking_date>=query.period_start&&e.movement.booking_date<query.period_end)active.push(row);
else outside++;
}
const coverage:Obj={};for(const k of ['source_id','receipt_ref','observed_at','coverage_start','coverage_end','head_sequence'])coverage[k]=head[k];
coverage.validated_through_sequence=head.head_sequence;coverage.visible_through_sequence=visibleThrough;
return[active,withdrawn,String(outside),coverage];
}
export function selectReconciliationRows(query:any,internalGroups:any,externalGroups:any,context:any,expectedSchema='d30-exact-reference-match-v1'):Obj {
try{
need(exact(query,QUERY),'INVALID_SCHEMA');need(query.schema_version===expectedSchema,'UNSUPPORTED_SCHEMA');
need(['ledger_id','entity_id','reconciliation_account_id'].every(k=>identifier(query[k])),'INVALID_IDENTIFIER');
const start=civil(query.period_start),end=civil(query.period_end);need(start!==null&&end!==null,'INVALID_DATE');
need((end-start)/86400000>=1&&(end-start)/86400000<=366,'INVALID_PERIOD');
need(timestamp(query.knowledge_cutoff),'INVALID_TIMESTAMP');
need(query.knowledge_cutoff>=query.period_end+'T00:00:00.000Z','KNOWLEDGE_BEFORE_PERIOD_END');
need(query.reference_policy==='BYTE_EXACT_ASCII_V1','UNSUPPORTED_REFERENCE_POLICY');
need(exact(context,['internal_head','external_head']),'INVALID_CONTEXT');
for(const side of ['internal','external']){
const h=context[side+'_head'];need(exact(h,HEAD),'INVALID_CONTEXT');
need(h.schema_version==='d30-reconciliation-head-v1'&&h.side===side,'INVALID_CONTEXT');
need(['source_id','ledger_id','entity_id','reconciliation_account_id','receipt_ref'].every(k=>identifier(h[k])),'INVALID_CONTEXT');
need(['ledger_id','entity_id','reconciliation_account_id'].every(k=>h[k]===query[k]),'SCOPE_MISMATCH');
const a=civil(h.coverage_start),b=civil(h.coverage_end);need(a!==null&&b!==null&&a<b,'INVALID_CONTEXT');
need(timestamp(h.observed_at)&&nonnegative(h.head_sequence),'INVALID_CONTEXT');
need(h.coverage_start<=query.period_start&&h.coverage_end>=query.period_end,'COVERAGE_WINDOW_MISMATCH');
need(query.knowledge_cutoff<=h.observed_at,'KNOWLEDGE_BEYOND_SOURCE');
}
need(context.internal_head.source_id!==context.external_head.source_id,'INVALID_CONTEXT');
const selected:Obj={internal:[],external:[],withdrawn:{internal:[],external:[]},out_of_window_counts:{internal:'0',external:'0'}},coverage:Obj={};
for(const [side,groups]of [['internal',internalGroups],['external',externalGroups]]as const){const [active,withdrawn,count,cov]=selectSource(query,groups,context[side+'_head']);selected[side]=active;selected.withdrawn[side]=withdrawn;selected.out_of_window_counts[side]=count;coverage[side]=cov}
return{status:'ok',code:'RECONCILIATION_ROWS_SELECTED',coverage,selected};
}catch(error){if(error instanceof ReconciliationError)return{status:INDETERMINATE.has(error.message)?'indeterminate':'rejected',code:error.message,coverage:null,selected:null};throw error}
}
export function referenceKey(reference:any):[string,string]|null {
if(reference===null||reference.value.replace(/ /g,'')===''||reference.value==='NOTPROVIDED')return null;
return[reference.namespace,reference.value];
}
// Fuzzy wrapper below a pinned self-contained reconciliation selector.
type _FuzzyObj=Record<string,any>;
type _FuzzyRat={n:bigint,d:bigint};
const _fuzzyCommon='schema_version ledger_id entity_id reconciliation_account_id period_start period_end knowledge_cutoff reference_policy'.split(' ');
const _fuzzyQuery=[..._fuzzyCommon,'policy'];
const _fuzzyPolicy='algorithm max_day_gap threshold_points min_margin_points'.split(' ');
const _fuzzyCopy=(x:any)=>structuredClone(x);
const _fuzzyExact=(x:any,f:string[])=>x!==null&&typeof x==='object'&&!Array.isArray(x)&&Object.keys(x).length===f.length&&f.every(k=>Object.hasOwn(x,k));
const _fuzzyInteger=(x:any,max:number)=>typeof x==='string'&&/^(0|[1-9][0-9]{0,2})$/.exec(x)?.[0]===x&&Number(x)<=max;
function _fuzzyFrac(n:bigint,d=1n):_FuzzyRat{let a=n<0n?-n:n,b=d;while(b){const r=a%b;a=b;b=r;}return {n:n/a,d:d/a};}
const _fuzzyWire=(x:_FuzzyRat|null)=>x===null?null:{numerator:String(x.n),denominator:String(x.d)};
const _fuzzyCmp=(a:_FuzzyRat,b:_FuzzyRat)=>a.n*b.d<b.n*a.d?-1:a.n*b.d>b.n*a.d?1:0;
const _fuzzySub=(a:_FuzzyRat,b:_FuzzyRat)=>_fuzzyFrac(a.n*b.d-b.n*a.d,a.d*b.d);
const _fuzzyRef=(row:_FuzzyObj)=>_fuzzyCopy(row.row_ref);
const _fuzzyFailed=(status:string,code:string):_FuzzyObj=>({status,code,coverage:null,selected:null,proposals:[],comparisons:[],row_decisions:[]});
function _fuzzyProfile(row:_FuzzyObj):_FuzzyObj{
const text=row.movement.description;
if([...text].some((c:any)=>c.codePointAt(0)<0x20||c.codePointAt(0)>0x7E))return {status:'unsupported',tokens:null};
const tokens=[...new Set<string>(text.toLowerCase().match(/[a-z0-9]+/g)||[])].sort();return {status:tokens.length?'comparable':'not-comparable',tokens};
}
function _fuzzyReference(row:_FuzzyObj):string[]|null{const v=row.movement.reference;if(v===null||v.value===''||[...v.value].every(c=>c===' ')||v.value==='NOTPROVIDED')return null;return [v.namespace,v.value];}
function _fuzzyRank(scores:_FuzzyRat[],score:_FuzzyRat,margin:number):_FuzzyObj{
const ordered=[...scores].sort((a,b)=>_fuzzyCmp(b,a)),best=ordered[0]!,runner=ordered.length>1?ordered[1]!:null,gap=runner===null?null:_fuzzySub(best,runner);
return {best_score_points:_fuzzyWire(best),is_best:_fuzzyCmp(score,best)===0,unique_best:ordered.filter(s=>_fuzzyCmp(s,best)===0).length===1,runner_up_score_points:_fuzzyWire(runner),margin_points:_fuzzyWire(gap),margin_pass:gap===null||gap.n>=BigInt(margin)*gap.d};
}
export function fuzzyTransactionMatch(query:any,internalGroups:any,externalGroups:any,context:any):_FuzzyObj{
if(!_fuzzyExact(query,_fuzzyQuery))return _fuzzyFailed('rejected','INVALID_SCHEMA');
if(query.schema_version!=='d30-fuzzy-transaction-match-v1')return _fuzzyFailed('rejected','UNSUPPORTED_SCHEMA');
const p=query.policy;if(!_fuzzyExact(p,_fuzzyPolicy)||p.algorithm!=='token-jaccard-date-v1'||!_fuzzyInteger(p.max_day_gap,7)||!_fuzzyInteger(p.threshold_points,100)||!_fuzzyInteger(p.min_margin_points,100))return _fuzzyFailed('rejected','INVALID_POLICY');
const common:_FuzzyObj={};for(const k of _fuzzyCommon)common[k]=query[k];
const source=selectReconciliationRows(common,internalGroups,externalGroups,context,'d30-fuzzy-transaction-match-v1');
if(source.status!=='ok')return _fuzzyFailed(source.status,source.code);
const selected=source.selected,internal:_FuzzyObj[]=selected.internal,external:_FuzzyObj[]=selected.external;if(internal.length>128||external.length>128)return _fuzzyFailed('rejected','FUZZY_RESOURCE_LIMIT');
const W=Number(p.max_day_gap),T=Number(p.threshold_points),M=Number(p.min_margin_points),ip=internal.map(_fuzzyProfile),ep=external.map(_fuzzyProfile);
const comparisons:_FuzzyObj[]=[],iscores:_FuzzyRat[][]=internal.map(()=>[]),escores:_FuzzyRat[][]=external.map(()=>[]),edges:{i:number,e:number,c:_FuzzyObj,score:_FuzzyRat}[]=[];
for(let i=0;i<internal.length;i++)for(let e=0;e<external.length;e++){
const left=internal[i]!,right=external[e]!,a=left.movement,b=right.movement,gap=(Date.parse(b.booking_date+'T00:00:00.000Z')-Date.parse(a.booking_date+'T00:00:00.000Z'))/86400000,reasons:string[]=[];
for(const [field,code] of [['currency','CURRENCY_MISMATCH'],['direction','DIRECTION_MISMATCH'],['amount_minor','AMOUNT_MISMATCH'],['is_reversal','REVERSAL_KIND_MISMATCH']])if(a[field!]!==b[field!])reasons.push(code!);
const lr=_fuzzyReference(left),rr=_fuzzyReference(right);if(lr!==null&&rr!==null&&lr[0]===rr[0]&&lr[1]!==rr[1])reasons.push('REFERENCE_CONFLICT');if(Math.abs(gap)>W)reasons.push('DATE_WINDOW_MISMATCH');
const c:_FuzzyObj={internal_row_ref:_fuzzyRef(left),external_row_ref:_fuzzyRef(right),booking_date_gap_days:gap,gate_reasons:reasons,text_status:'not-evaluated',internal_tokens:null,external_tokens:null,token_intersection_count:null,token_union_count:null,token_jaccard:null,date_component:null,score_points:null,threshold_pass:false,internal_rank:null,external_rank:null,proposed:false,reason:'HARD_GATE_FAILED'};comparisons.push(c);if(reasons.length)continue;
if(ip[i]!.status==='unsupported'||ep[e]!.status==='unsupported'){Object.assign(c,{text_status:'unsupported',reason:'TEXT_PROFILE_UNSUPPORTED'});continue;}
if(ip[i]!.status==='not-comparable'||ep[e]!.status==='not-comparable'){Object.assign(c,{text_status:'not-comparable',reason:'TEXT_NOT_COMPARABLE'});continue;}
const U=new Set<string>(ip[i]!.tokens),V=new Set<string>(ep[e]!.tokens),intersection=[...U].filter(t=>V.has(t)).length,union=new Set([...U,...V]).size,J=_fuzzyFrac(BigInt(intersection),BigInt(union)),D=_fuzzyFrac(BigInt(W+1-Math.abs(gap)),BigInt(W+1)),score=_fuzzyFrac(80n*J.n*D.d+20n*D.n*J.d,J.d*D.d);
Object.assign(c,{text_status:'comparable',internal_tokens:[...ip[i]!.tokens],external_tokens:[...ep[e]!.tokens],token_intersection_count:intersection,token_union_count:union,token_jaccard:_fuzzyWire(J),date_component:_fuzzyWire(D),score_points:_fuzzyWire(score),threshold_pass:score.n>=BigInt(T)*score.d});iscores[i]!.push(score);escores[e]!.push(score);edges.push({i,e,c,score});
}
const proposals:_FuzzyObj[]=[];
for(const {i,e,c,score} of edges){const ir=_fuzzyRank(iscores[i]!,score,M),er=_fuzzyRank(escores[e]!,score,M);c.internal_rank=ir;c.external_rank=er;
const reason=!c.threshold_pass?'BELOW_THRESHOLD':!ir.is_best||!er.is_best?'NOT_MUTUAL_BEST':!ir.unique_best||!er.unique_best?'BEST_SCORE_TIE':!ir.margin_pass||!er.margin_pass?'INSUFFICIENT_MARGIN':'MUTUAL_UNIQUE_BEST_WITH_MARGIN';c.reason=reason;c.proposed=reason==='MUTUAL_UNIQUE_BEST_WITH_MARGIN';
if(c.proposed)proposals.push({internal_row_ref:_fuzzyRef(internal[i]!),external_row_ref:_fuzzyRef(external[e]!),score_points:_fuzzyWire(score),token_intersection_count:c.token_intersection_count,token_union_count:c.token_union_count,booking_date_gap_days:c.booking_date_gap_days,internal_margin_points:_fuzzyCopy(ir.margin_points),external_margin_points:_fuzzyCopy(er.margin_points),reason,status:'review-proposal'});
}
const decisions:_FuzzyObj[]=[];
for(const side of ['internal','external']){
const rows=side==='internal'?internal:external,profiles=side==='internal'?ip:ep,scores=side==='internal'?iscores:escores;
for(let i=0;i<rows.length;i++){
const row=rows[i]!,related=comparisons.filter(c=>c[side+'_row_ref'].row_id===row.row_ref.row_id),proposed=related.find(c=>c.proposed)||null,ordered=[...scores[i]!].sort((a,b)=>_fuzzyCmp(b,a)),rank=ordered.length?_fuzzyRank(ordered,ordered[0]!,M):null;
let decision:string,reason:string;
if(proposed!==null){decision='proposed';reason='MUTUAL_UNIQUE_BEST_WITH_MARGIN';}
else if(profiles[i]!.status==='unsupported'){decision='unsupported-text';reason='TEXT_PROFILE_UNSUPPORTED';}
else if(profiles[i]!.status==='not-comparable'){decision='text-not-comparable';reason='TEXT_NOT_COMPARABLE';}
else if(!ordered.length){decision='no-eligible-candidate';reason='NO_ELIGIBLE_CANDIDATE';}
else if(ordered[0]!.n<BigInt(T)*ordered[0]!.d){decision='below-threshold';reason='BELOW_THRESHOLD';}
else if(!rank!.unique_best){decision='review-ambiguous';reason='BEST_SCORE_TIE';}
else{decision='review-ambiguous';reason=related.find(c=>c[side+'_rank']!==null&&c[side+'_rank'].is_best)!.reason;}
decisions.push({side,row_ref:_fuzzyRef(row),text_profile_status:profiles[i]!.status,tokens:_fuzzyCopy(profiles[i]!.tokens),best_score_points:rank===null?null:_fuzzyCopy(rank.best_score_points),runner_up_score_points:rank===null?null:_fuzzyCopy(rank.runner_up_score_points),margin_points:rank===null?null:_fuzzyCopy(rank.margin_points),decision,reason,proposed_counterpart_ref:proposed===null?null:_fuzzyCopy(proposed[(side==='internal'?'external':'internal')+'_row_ref'])});
}
}
return {status:'ok',code:'FUZZY_PROPOSALS_READY',coverage:_fuzzyCopy(source.coverage),selected:_fuzzyCopy(selected),proposals,comparisons,row_decisions:decisions};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
