#!/usr/bin/env python3
"""Offline source consistency and independent model replay. No network or dependencies.
Usage: python3 verify_bundle.py case.json [--digest SEPARATELY_RETAINED_SHA256]
Decrypt encrypted cases in Waybrio and export evidence JSON before offline verification.
A matching digest is not source authenticity, identity or legal chain of custody.
"""
import argparse
import hashlib
import json
import re
import sys
from datetime import datetime
from pathlib import Path

HEADERS = ['chain','tx_hash','event_index','from_address','to_address','asset','asset_id','amount_raw','decimals','timestamp','source','retrieved_at']
CLASSES = ['SIMULATED DEMO — FICTIONAL DATA','IMPORTED — NOT INDEPENDENTLY VERIFIED']
POLICIES = ['proportional','source-first','source-last']
TRIM = '\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff'
ASSUMPTIONS = [
 'Single-network, single-asset account-style model from the seed recipient onward. No UTXO input/output reconstruction, fees, swaps or bridges.',
 'Other balance at the seed recipient is supplied by the analyst. Other addresses begin at zero. Pre-seed records are excluded.',
 'Insufficient balance is filled by the minimum unobserved non-source funding needed to explain that debit; this is a model assumption.',
 'Proportional division floors to atomic units; remainder stays at the sender. End holdings plus stopped holdings conserve seed mass.',
 'Source-first and source-last are allocation scenarios, not FIFO/LIFO implementations. The displayed range is model disagreement, not a complete feasible interval or probability.',
 'Outgoing boundary transfers credit gross value to recipients as unclassified background for this model, while source mass is frozen at the frontier. That background is not proven clean and may contain unresolved seed funds.',
 'Addresses are compared as exact supplied strings. Equivalent encodings or casing are not normalized.',
 'Stopped holdings describe the unresolved analytical frontier, not a verified current wallet balance. No laundering or identity verdict is produced.'
]

def require(condition, message):
    if not condition:
        raise ValueError(message)

def strict_json(value):
    def invalid_constant(value):
        raise ValueError('Non-JSON numeric constant: ' + value)
    return json.loads(value,parse_constant=invalid_constant)

def js_len(value):
    return len(value.encode('utf-16-le')) // 2

def string(value, label, max_len=300, nonempty=True):
    require(type(value) is str and js_len(value) <= max_len and (not nonempty or bool(value.strip(TRIM))), 'Invalid ' + label)
    return value

def digest(value):
    return hashlib.sha256(value.encode('utf-8')).hexdigest()

def equal(a, b):
    if type(a) in (int,float) and type(b) in (int,float):
        return a == b
    if type(a) is not type(b):
        return False
    if isinstance(a, dict):
        return a.keys() == b.keys() and all(equal(a[k], b[k]) for k in a)
    if isinstance(a, list):
        return len(a) == len(b) and all(equal(x, y) for x, y in zip(a, b))
    return a == b

def same(a, b, label):
    require(equal(a, b), label + ' MISMATCH')

def utc(value, milliseconds=False):
    pattern = r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}' + (r'(?:\.\d{3})?' if milliseconds else '') + 'Z'
    require(type(value) is str and re.fullmatch(pattern, value), 'Invalid UTC timestamp')
    calendar_value = '2000' + value[4:] if value.startswith('0000-') else value
    datetime.strptime(calendar_value, '%Y-%m-%dT%H:%M:%S.%fZ' if '.' in value else '%Y-%m-%dT%H:%M:%SZ')
    return value

def parse_csv(text):
    require(len(text.encode('utf-8')) <= 1000000, 'CSV exceeds 1 MB')
    rows, row, cell = [], [], ''
    quoted, closed, i = False, False, 0
    if text.startswith('\ufeff'):
        text = text[1:]
    while i < len(text):
        c = text[i]
        if quoted:
            if c == '"':
                if i + 1 < len(text) and text[i+1] == '"':
                    cell += '"'; i += 1
                else:
                    quoted = False; closed = True
            else:
                cell += c
        elif c == '"':
            require(not cell and not closed, 'Unexpected CSV quote')
            quoted = True
        elif c in ',\n\r':
            row.append(cell); cell = ''; closed = False
            if c != ',':
                if c == '\r' and i+1 < len(text) and text[i+1] == '\n':
                    i += 1
                if any(v != '' for v in row): rows.append(row)
                row = []
        else:
            require(not closed, 'Content after CSV closing quote')
            cell += c
        i += 1
    require(not quoted, 'Unclosed CSV quote')
    row.append(cell)
    if any(v != '' for v in row): rows.append(row)
    require(2 <= len(rows) <= 501, 'CSV must contain 1–500 transfers')
    header = [v.strip(TRIM) for v in rows[0]]
    require(len(set(header)) == len(header) and set(HEADERS) <= set(header), 'CSV schema mismatch')
    transfers, seen, assets = [], set(), {}
    for index, cells in enumerate(rows[1:], 2):
        require(len(cells) == len(header), 'CSV column count mismatch')
        r = dict(zip(header, [v.strip(TRIM) for v in cells]))
        for k in HEADERS: string(r[k], k)
        require(re.fullmatch(r'[a-z0-9][a-z0-9:_-]*', r['chain']), 'Invalid chain')
        require(re.fullmatch(r'(0|[1-9][0-9]{0,99})', r['amount_raw']), 'Invalid atomic amount')
        require(re.fullmatch(r'(0|[1-9][0-9]?)', r['decimals']) and int(r['decimals']) <= 36, 'Invalid decimals')
        require(re.fullmatch(r'(0|[1-9][0-9]{0,9})', r['event_index']), 'Invalid event index')
        utc(r['timestamp']); utc(r['retrieved_at'])
        require(r['retrieved_at'] >= r['timestamp'], 'Retrieval predates transfer')
        identity = (r['chain'], r['tx_hash'], r['event_index'])
        require(identity not in seen, 'Duplicate transfer event'); seen.add(identity)
        asset_key, metadata = (r['chain'], r['asset_id']), (r['asset'], r['decimals'])
        require(asset_key not in assets or assets[asset_key] == metadata, 'Asset metadata conflict'); assets[asset_key] = metadata
        r.update(row=index, id='row-' + str(index)); transfers.append(r)
    return transfers

def formatted(raw, decimals):
    places = int(decimals)
    value = str(raw).zfill(places + 1)
    return value if not places else value[:-places] + '.' + value[-places:]

def view_and_totals(rows, f):
    require(type(f) is dict and set(f) <= {'chain','asset','address','from','to','exactAddress'}, 'Invalid filters')
    for k in ['chain','asset','address','from','to']: string(f[k], 'filter ' + k, nonempty=False)
    require('exactAddress' not in f or type(f['exactAddress']) is bool, 'Invalid exact address filter')
    for k in ['from','to']:
        if f[k]: utc(f[k] + 'T00:00:00Z')
    require(not f['from'] or not f['to'] or f['from'] <= f['to'], 'Reversed date filter')
    visible = [r for r in rows if (not f['chain'] or f['chain'] == r['chain']) and (not f['asset'] or f['asset'] == r['asset']) and
        (not f['address'] or (f['address'] in (r['from_address'], r['to_address']) if f.get('exactAddress') else f['address'] in r['from_address'] or f['address'] in r['to_address'])) and
        (not f['from'] or r['timestamp'][:10] >= f['from']) and (not f['to'] or r['timestamp'][:10] <= f['to'])]
    totals = {}
    for r in visible:
        k = (r['chain'], r['asset_id'])
        if k not in totals: totals[k] = {key:r[key] for key in ['chain','asset','asset_id','decimals']} | {'raw':0}
        totals[k]['raw'] += int(r['amount_raw'])
    result = [v | {'raw':str(v['raw']), 'formatted':formatted(v['raw'],v['decimals'])} for v in totals.values()]
    return [r['id'] for r in visible], result

def dossier(raw):
    require(len(raw.encode('utf-8')) <= 500000, 'Platform input exceeds 500 KB')
    p = strict_json(raw[1:] if raw.startswith('\ufeff') else raw)
    require(p['format'] == 'waybrio-platform-evidence/1' and p['classification'] in ('synthetic','supplied'), 'Invalid platform format')
    normalized = {'format':p['format'], 'classification':p['classification']}
    fields = {'sources':['id','name','content','collected_at','locator','authority_basis','independence_group'], 'entities':['id','kind','label','namespace','external_id']}
    for group in ['sources','entities','relations']:
        require(type(p[group]) is list and len(p[group]) <= (100 if group == 'relations' else 50), 'Invalid platform list')
        values = []
        for value in p[group]:
            require(type(value) is dict, 'Invalid platform record')
            if group in fields:
                limits = {'content':20000, 'locator':1000, 'authority_basis':1000}
                result = {k:string(value[k],k,limits.get(k,300)) for k in fields[group]}
                if group == 'sources': utc(result['collected_at'])
                else: require(result['kind'] in ['creator_profile','platform','content_reference','checkout','payment_account','wallet'], 'Unknown entity kind')
            else:
                result = {k:string(value[k],k,2000 if k == 'analyst_rationale' else 300) for k in ['id','from','to','predicate','status','analyst_rationale','review_state']}
                require(result['predicate'] in ['hosted_on','published_link','references_payment_account','references_wallet','receipt_reports_payment','possible_same_controller'], 'Unknown relationship predicate')
                require(result['status'] in ['observed','asserted','hypothesized'] and result['review_state'] in ['unreviewed','disputed'], 'Invalid relationship state')
                require(result['predicate'] != 'possible_same_controller' or result['status'] == 'hypothesized', 'Controller link must remain a hypothesis')
                require(type(value['source_refs']) is list and 1 <= len(value['source_refs']) <= 20, 'Missing source references')
                result['source_refs'] = [{'source_id':string(ref['source_id'],'source ID'),'record_locator':string(ref['record_locator'],'record locator',1000)} for ref in value['source_refs']]
                require(type(value['alternatives']) is list and 1 <= len(value['alternatives']) <= 10, 'Missing alternatives')
                result['alternatives'] = [string(v,'alternative',1000) for v in value['alternatives']]
            values.append(result)
        require(len({v['id'] for v in values}) == len(values), 'Duplicate platform IDs')
        normalized[group] = values
    for r in normalized['relations']:
        require(all(any(e['id'] == i for e in normalized['entities']) for i in [r['from'],r['to']]), 'Unresolved entity')
        require(all(any(s['id'] == ref['source_id'] for s in normalized['sources']) for ref in r['source_refs']), 'Unresolved source')
    return normalized

def replay(rows, config):
    require(type(config) is dict and set(config) == {'seedId','backgroundRaw','stopAddresses'}, 'Invalid flow config')
    require(type(config['backgroundRaw']) is str and re.fullmatch(r'(0|[1-9][0-9]{0,99})',config['backgroundRaw']), 'Invalid opening balance')
    require(type(config['stopAddresses']) is list and len(config['stopAddresses']) <= 100 and all(type(a) is str and 1 <= js_len(a) <= 300 for a in config['stopAddresses']), 'Invalid boundaries')
    seed = next(r for r in rows if r['id'] == config['seedId'])
    mass = int(seed['amount_raw']); require(mass > 0, 'Invalid seed mass')
    ordered = sorted([r for r in rows if r['chain'] == seed['chain'] and r['asset_id'] == seed['asset_id']], key=lambda r:(r['timestamp'],r['row']))
    future = ordered[ordered.index(seed)+1:]
    after = [r for r in ordered if r['timestamp'] >= seed['timestamp']]
    ambiguous = any(a['timestamp'] == b['timestamp'] for a,b in zip(after,after[1:]))
    stop, runs = set(config['stopAddresses']), []
    for policy in POLICIES:
        balances = {seed['to_address']:[mass+int(config['backgroundRaw']),mass]}
        allocations, gaps = [], []
        for r in future:
            value = int(r['amount_raw']); sender = balances.setdefault(r['from_address'],[0,0])
            if r['from_address'] in stop:
                if r['from_address'] != r['to_address']: balances.setdefault(r['to_address'],[0,0])[0] += value
                allocations.append({'id':r['id'],'raw':'0','status':'stopped'}); continue
            if r['from_address'] == r['to_address']:
                allocations.append({'id':r['id'],'raw':'0','status':'self-transfer'}); continue
            if sender[0] < value:
                gaps.append({'id':r['id'],'address':r['from_address'],'raw':str(value-sender[0])}); sender[0] = value
            allocated = (sender[1]*value//sender[0] if sender[0] else 0) if policy == 'proportional' else min(sender[1],value) if policy == 'source-first' else max(0,value-(sender[0]-sender[1]))
            sender[0] -= value; sender[1] -= allocated
            recipient = balances.setdefault(r['to_address'],[0,0]); recipient[0] += value; recipient[1] += allocated
            allocations.append({'id':r['id'],'raw':str(allocated),'status':'allocated'})
        holdings = [{'address':a,'raw':str(v[1]),'stopped':a in stop} for a,v in balances.items() if v[1] > 0]
        require(sum(int(h['raw']) for h in holdings) == mass, 'Flow conservation failure')
        runs.append({'policy':policy,'allocations':allocations,'holdings':holdings,'fundingGaps':gaps,'conserved':True})
    reached, comparisons = {seed['to_address']}, []
    for index, r in enumerate(future):
        reachable = r['from_address'] in reached and r['from_address'] not in stop and r['from_address'] != r['to_address'] and int(r['amount_raw']) > 0
        if reachable: reached.add(r['to_address'])
        values = [int(run['allocations'][index]['raw']) for run in runs]
        comparisons.append({'id':r['id'],'proportional':str(values[0]),'sourceFirst':str(values[1]),'sourceLast':str(values[2]),'scenarioMin':str(min(values)),'scenarioMax':str(max(values)),'reachability':r['amount_raw'] if reachable else '0','status':runs[0]['allocations'][index]['status']})
    addresses = list(dict.fromkeys(h['address'] for run in runs for h in run['holdings']))
    holdings = [{'address':a,'values':[next((h['raw'] for h in run['holdings'] if h['address'] == a),'0') for run in runs],'stopped':a in stop} for a in addresses]
    requests = [{'priority':1,'record':'Opening balance and complete asset ledger','reason':'Confirm balance at the seed time. Zero balances and minimum unknown funding are scenario assumptions, not evidence.'}]
    if ambiguous: requests.append({'priority':2,'record':'Block and transaction execution order','reason':'Equal timestamps are resolved by CSV record order; another order may change allocations.'})
    if stop: requests.append({'priority':2,'record':'Custodian, mixer or platform payout records','reason':'Provide lawful source records connecting deposits to withdrawals before extending the marked frontier.'})
    if any(c['scenarioMin'] != c['scenarioMax'] for c in comparisons): requests.append({'priority':3,'record':'Payout-to-transfer reconciliation','reason':'Allocation models disagree. A platform payout ledger may distinguish competing payment paths.'})
    return {'version':'fractional-flow-lab/0.2.0','config':config,'seedId':seed['id'],'seedRaw':str(mass),'chain':seed['chain'],'asset':seed['asset'],'asset_id':seed['asset_id'],'decimals':seed['decimals'],'ignoredRecords':len(rows)-1-len(future),'orderAmbiguous':ambiguous,'runs':runs,'comparisons':comparisons,'holdings':holdings,'requests':requests,'assumptions':ASSUMPTIONS}

def range_model(rows, config, target):
    seed = next((r for r in rows if r['id'] == config['seedId']),None)
    require(seed is not None,'Select a seed transfer.')
    require(type(config['backgroundRaw']) is str and re.fullmatch(r'(0|[1-9][0-9]{0,99})',config['backgroundRaw']),'Other balance must be an atomic integer string, up to 100 digits.')
    require(int(seed['amount_raw']) > 0,'Choose a seed transfer with a positive amount.')
    require(type(config['stopAddresses']) is list and len(config['stopAddresses']) <= 100 and all(type(a) is str and 1 <= js_len(a) <= 300 for a in config['stopAddresses']),'Use up to 100 valid boundary addresses.')
    analysis = replay(rows, config)
    require(seed['from_address'] != seed['to_address'],'A self-transfer cannot seed this range model.')
    require(not config['stopAddresses'],'Range unavailable while any stop boundary is configured. Unresolved custody cannot be included in this model.')
    require(not analysis['orderAmbiguous'],'Range unavailable: equal timestamps leave execution order unresolved. Supply uniquely ordered records before using this model.')
    ordered = sorted([r for r in rows if r['chain'] == seed['chain'] and r['asset_id'] == seed['asset_id']],key=lambda r:(r['timestamp'],r['row']))
    future = ordered[ordered.index(seed)+1:]
    addresses = {seed['to_address']} | {a for r in future for a in [r['from_address'],r['to_address']]}
    require(target in addresses,'Select an address present in the modeled record set.')
    next_node, edges, injections, states, noops = 2, [], [], {}, []
    def state(address):
        nonlocal next_node
        if address not in states:
            states[address] = {'node':next_node,'balance':0}; next_node += 1
        return states[address]
    def edge(start, end, capacity, kind, address='', record_id=''):
        edges.append({'from':start,'to':end,'capacityRaw':str(capacity),'kind':kind,'address':address,'recordId':record_id})
    mass, background = int(seed['amount_raw']), int(config['backgroundRaw'])
    opening = state(seed['to_address']); opening['balance'] = mass + background
    edge(0,opening['node'],mass,'seed',seed['to_address'],seed['id'])
    if background: injections.append({'node':opening['node'],'address':seed['to_address'],'raw':str(background),'kind':'opening-background','recordId':seed['id']})
    for r in future:
        amount, sender, receiver = int(r['amount_raw']), state(r['from_address']), state(r['to_address'])
        if not amount or r['from_address'] == r['to_address']:
            noops.append(r['id']); continue
        if sender['balance'] < amount:
            injections.append({'node':sender['node'],'address':r['from_address'],'raw':str(amount-sender['balance']),'kind':'assumed-funding','recordId':r['id']}); sender['balance'] = amount
        new_sender, new_receiver = next_node, next_node+1; next_node += 2
        edge(sender['node'],new_sender,sender['balance']-amount,'carry',r['from_address'],r['id'])
        edge(receiver['node'],new_receiver,receiver['balance'],'carry',r['to_address'],r['id'])
        edge(sender['node'],new_receiver,amount,'transfer','',r['id'])
        states[r['from_address']] = {'node':new_sender,'balance':sender['balance']-amount}
        states[r['to_address']] = {'node':new_receiver,'balance':receiver['balance']+amount}
    for address, value in states.items(): edge(value['node'],1,value['balance'],'holding',address)
    included = {seed['id']} | {r['id'] for r in future}
    model = {'nodeCount':next_node,'source':0,'sink':1,'seedRaw':str(mass),'chain':seed['chain'],'asset_id':seed['asset_id'],'asset':seed['asset'],'decimals':seed['decimals'],'seedId':seed['id'],'orderedIds':[r['id'] for r in future],'excludedIds':[r['id'] for r in rows if r['id'] not in included],'noOpIds':noops,'edges':edges,'injections':injections,'orderRule':'Supplied UTC chronological order; no equal timestamps at or after the seed. This is not authenticated execution order.'}
    balance = [0]*next_node
    for e in edges:
        balance[e['from']] -= int(e['capacityRaw']); balance[e['to']] += int(e['capacityRaw'])
    for item in injections: balance[item['node']] += int(item['raw'])
    require(all(v == 0 for v in balance[2:]) and balance[0] == -mass, 'Reconstructed gross model inconsistent')
    return model

def natural(value):
    require(type(value) is str and re.fullmatch(r'(0|[1-9][0-9]{0,105})',value), 'Invalid certificate integer')
    return int(value)

def verify_range(rows, config, result):
    require(type(result) is dict and result['version'] == 'conditional-flow-range/0.1.0', 'Unsupported model certificate')
    string(result['target'],'range target',300)
    expected_keys = {'version','target','status','reasons'} if result['status'] == 'unavailable' else {'version','target','status','model','minimumRaw','maximumRaw','lower','upper'}
    require(set(result) == expected_keys,'Unexpected range certificate fields')
    try: model = range_model(rows,config,result['target'])
    except ValueError as error:
        require(result['status'] == 'unavailable','Unavailable range has a certificate')
        same(result['reasons'],[str(error)],'UNAVAILABLE REASON')
        return
    require(result['status'] == 'available', 'Available model has no certificate')
    same(result['model'],model,'RANGE MODEL')
    mass, low, high = int(model['seedRaw']),natural(result['minimumRaw']),natural(result['maximumRaw'])
    require(low <= high <= mass,'Invalid conditional range')
    for endpoint, certificate, prefer_target in [(low,result['lower'],False),(high,result['upper'],True)]:
        require(type(certificate) is dict and set(certificate) == {'flows','cut'},'Unexpected endpoint certificate fields')
        require(type(certificate['flows']) is list and len(certificate['flows']) == len(model['edges']), 'Incomplete allocation witness')
        balance, target_value = [0]*model['nodeCount'], 0
        for edge, raw in zip(model['edges'],certificate['flows']):
            value = natural(raw); require(value <= int(edge['capacityRaw']),'Range capacity exceeded')
            balance[edge['from']] -= value; balance[edge['to']] += value
            if edge['kind'] == 'holding' and edge['address'] == result['target']: target_value += value
        require(balance[0] == -mass and balance[1] == mass and all(v == 0 for v in balance[2:]) and target_value == endpoint,'Range source conservation or endpoint mismatch')
        partition = certificate['cut']
        require(type(partition) is list and all(type(n) in (int,float) and n == int(n) and 0 <= n < model['nodeCount'] for n in partition) and len(set(partition)) == len(partition),'Invalid cut partition')
        cut = set(partition); require(0 in cut and 1 not in cut,'Cut does not separate endpoints')
        capacity = sum(int(e['capacityRaw']) for e in model['edges'] if e['from'] in cut and e['to'] not in cut and (e['kind'] != 'holding' or ((e['address'] == result['target']) == prefer_target)))
        require(capacity == (endpoint if prefer_target else mass-endpoint),'Cut does not prove the claimed optimum')

def verify(path, expected_digest=None):
    require(Path(path).stat().st_size <= 20000000, 'Case envelope exceeds 20 MB')
    envelope = strict_json(Path(path).read_text(encoding='utf-8-sig'))
    require(envelope['format'] == 'waybrio-evidence-envelope/1' and envelope['algorithm'] == 'SHA-256', 'Unsupported format or algorithm')
    payload = string(envelope['payload_json'],'payload',20000000)
    fingerprint = digest(payload)
    require(fingerprint == envelope['payload_sha256'], 'PAYLOAD DIGEST MISMATCH')
    if expected_digest is not None:
        require(re.fullmatch('[0-9a-fA-F]{64}',expected_digest) and fingerprint == expected_digest.lower(), 'RETAINED DIGEST MISMATCH')
    data = strict_json(payload)
    require(data['version'] in ['waybrio-evidence/0.2.0','waybrio-evidence/0.3.0','waybrio-evidence/0.4.0'], 'Unsupported case version')
    require(data['classification'] in CLASSES and data['source']['classification'] == data['classification'], 'Case classification mismatch')
    string(data['case_name'],'case name',100,False); string(data['analyst_hypothesis'],'analyst hypothesis',4000,False)
    utc(data['exported_at'],True); utc(data['source']['loaded_at'],True); string(data['source']['name'],'source name',1024,False)
    source = string(data['source']['content'],'CSV source',1000000)
    require(digest(source) == data['source']['sha256'], 'SOURCE DIGEST MISMATCH')
    rows = parse_csv(source); same(data['transfers'],rows,'NORMALIZED TRANSFERS')
    visible, totals = view_and_totals(rows,data['filters'])
    same(data['visible_ids'],visible,'VISIBLE IDS'); same(data['totals'],totals,'TOTALS')
    pins = data['pinned_ids']; require(type(pins) is list and all(type(i) is str for i in pins) and len(set(pins)) == len(pins) and set(pins) <= {r['id'] for r in rows}, 'Invalid pins')
    research = data['research']
    if research is not None:
        sidecar = research['platform_evidence']
        if sidecar is not None:
            raw = sidecar['original_json']; require(digest(raw) == sidecar['sha256'],'PLATFORM INPUT DIGEST MISMATCH')
            normalized = dossier(raw); same(sidecar['dossier'],normalized,'PLATFORM NORMALIZATION')
            require((normalized['classification'] == 'synthetic') == (data['classification'] == CLASSES[0]), 'Platform classification mismatch')
            hashes = [{'id':s['id'],'sha256':digest(s['content'])} for s in normalized['sources']]
            same(sidecar['source_hashes'],hashes,'PLATFORM SOURCE DIGEST')
        flow = research['flow_analysis']
        if flow is not None:
            require(flow['version'] == 'fractional-flow-lab/0.2.0', 'Unsupported flow version')
            calculated = replay(rows,flow['config'])
            for run in flow['runs']:
                require(sum(int(h['raw']) for h in run['holdings']) == int(calculated['seedRaw']), 'Flow conservation failure')
            same(flow,calculated,'FLOW REPLAY')
        if 'flow_controls' in research:
            controls = research['flow_controls']
            require(type(controls) is dict and set(controls) == {'seedId','backgroundRaw','stopAddresses'}, 'Invalid saved flow controls')
            string(controls['seedId'],'seed control',300,False); string(controls['backgroundRaw'],'balance control',1000,False)
            require(type(controls['stopAddresses']) is list and len(controls['stopAddresses']) <= 100, 'Invalid saved boundaries')
            for address in controls['stopAddresses']: string(address,'saved boundary',300,False)
            if flow is not None: same(controls,flow['config'],'SAVED FLOW CONTROLS')
            else:
                try: replay(rows,controls)
                except (ValueError,StopIteration): pass
                else: raise ValueError('Missing calculated flow for valid saved controls')
        if 'range_target' in research:
            target = string(research['range_target'],'range target',300,False)
            result = research['conditional_range']
            if target:
                require(result is not None and result['target'] == target and 'flow_controls' in research,'Range selection mismatch')
                verify_range(rows,research['flow_controls'],result)
            else: require(result is None,'Range has no target')
        else: require('conditional_range' not in research,'Missing range target')
    return fingerprint, data

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('case_file'); parser.add_argument('--digest')
    args = parser.parse_args()
    try:
        fingerprint, data = verify(args.case_file,args.digest)
        print('Integrity checks passed:',fingerprint)
        print('CSV normalization, views, supplied model replay and any conditional range certificates checked.')
        print('Dataset:',data['classification'],'Records:',len(data['transfers']),'Visible:',len(data['visible_ids']))
        print('Not source authenticity, on-chain validation or chain-of-custody certification.')
    except (KeyError,ValueError,OSError,IndexError,TypeError,StopIteration,OverflowError,RecursionError,AttributeError) as exc:
        print('Verification failed:',exc,file=sys.stderr)
        sys.exit(1)
