"""CSV inference and visual review. Run with the training script's Python dependencies.""" from __future__ import annotations import argparse import csv import html import json import random import time import unicodedata from typing import cast from dataclasses import asdict from pathlib import Path import xml.etree.ElementTree as ET import torch from train_tabfix import (BUSINESS_CATEGORIES as CATEGORIES, Consumer, Edit, Record, generate_repair, load_checkpoint, predict_edits, deterministic_findings, deterministic_repair, conditional_perplexity, replace_cell_xml, read_json, mapping, number) def read_csv(path: Path) -> list[list[str]]: with path.open(encoding="utf-8-sig", newline="") as stream: rows = list(csv.reader(stream)) if not rows or not rows[0] or any(len(r) != len(rows[0]) for r in rows): raise ValueError("Expected a rectangular, comma-delimited UTF-8 CSV with a header") return rows def write_csv(path: Path, rows: list[list[str]]) -> None: with path.open("w", encoding="utf-8", newline="") as stream: csv.writer(stream).writerows(rows) def context_xml(rows: list[list[str]], selected: list[int], schema: str) -> str: chunks = [f'', schema, ''] for i in selected: chunks.append(f'') for header, value in zip(rows[0], rows[i+1]): chunks.append(f''+(html.escape(value) if value else '')+'') chunks.append('') return '\n'.join(chunks+['', '
']) def review(rows: list[list[str]], result: list[list[str]], reference: list[list[str]] | None, audit: list[dict[str, object]], stats: dict[str, object]) -> str: def show(value: str) -> str: quoted = json.dumps(value, ensure_ascii=False) visible = ''.join(f"\\u{ord(c):04x}" if unicodedata.category(c) == 'Cf' else c for c in quoted) return html.escape(visible) details: list[str] = [] for i, row in enumerate(rows[1:]): for j, value in enumerate(row): actual = result[i+1][j] expected = reference[i+1][j] if reference else None relevant = actual != value or (expected is not None and expected != value) or any(a['row']==i and a['column']==j for a in audit) if not relevant: continue status = 'Changed' if actual != value else 'Unchanged' if expected is not None: status = ('Repaired' if actual == expected else 'Missed / incorrect') if value != expected else ('Unwanted change' if actual != value else 'Preserved') color = 'good' if status in ('Repaired','Preserved') else 'bad' categories = ', '.join(str(a['category']) for a in audit if a['row']==i and a['column']==j) or 'No detection' details.append(f'{i+1}{html.escape(rows[0][j])}{show(value)}{show(actual)}{show(expected) if expected is not None else "—"}{status}{html.escape(categories)}') full = 'Data row'+''.join(''+html.escape(h)+'' for h in rows[0])+'' for i,row in enumerate(result[1:]): full += f'{i+1}'+''.join(''+html.escape(v)+'' for j,v in enumerate(row))+'' cards = ''.join('
'+html.escape(str(stats.get(key,'—')))+''+label+'
' for key,label in [('cells','Cells scanned'),('initial_faulty_cells','Errors in input'),('exactly_repaired_cells','Errors repaired'),('valid_cells_changed','Valid cells changed')]) return '''TabFix — end-to-end CSV review

Does TabFix fix this CSV?

Actual CSV → schema and context → predicted error locations → generated replacements → corrected CSV. The clean reference is used only after inference. No gold error locations or replacement text are supplied to the model. Row numbers below count data rows, excluding the header.

'''+'
'+cards+'

CSV inference review · Threshold '+str(stats['threshold'])+' · CPU inference '+str(stats['inference_seconds'])+' seconds (model loading excluded).

Errors and proposed changes

'+''.join(details)+'
RowColumnInputModel outputReferenceResult

Strings are quoted to make spaces and empty values visible. Yellow cells below were changed. All other cells are included so unwanted edits remain visible.

Complete model output

'+full+'
Every detection and application decision
'+html.escape(json.dumps(audit,ensure_ascii=False,indent=2))+'
' def run(csv_path: Path, checkpoint: Path, output: Path, schema_path: Path | None, reference_path: Path | None, threshold: float, enabled: set[str]) -> None: if output.resolve() == csv_path.parent.resolve(): raise ValueError("Use a separate output directory to preserve inputs") output.mkdir(parents=True, exist_ok=True) rows = read_csv(csv_path) schema = schema_path.read_text() if schema_path else '\n'+'\n'.join(f'' for i,h in enumerate(rows[0]))+'\n' element = ET.fromstring(schema) if element.tag != 'schema' or [c.get('name') for c in element.findall('column')] != rows[0]: raise ValueError("Schema columns must match the CSV header exactly") torch.set_num_threads(4) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') begin = time.perf_counter() model,tok,_ = load_checkpoint(checkpoint,device) load_seconds = time.perf_counter()-begin consumer = Consumer(tok) result = [r.copy() for r in rows] audit: list[dict[str,object]] = [] n = len(rows)-1 begin = time.perf_counter() if device.type == 'cuda': torch.cuda.reset_peak_memory_stats() for start in range(0,n,12): targets = set(range(start,min(n,start+12))) context = targets | set(range(min(n,2))) | set(range(max(0,start-2),min(n,start+14))) other = sorted(set(range(n))-context) context.update(random.Random(start+42).sample(other,min(4,len(other)))) xml = context_xml(rows,sorted(context),schema) record = Record('csv',xml,[],{'target_rows':[], 'supervised_columns':[], 'label_scope':'unknown'}) prepared = consumer.prepare(record) violations = [f for f in deterministic_findings(xml) if f.status == 'invalid' and f.category in enabled and f.row in targets] proposed = {(e.row, e.column): e for e in predict_edits(model, consumer, prepared, device, enabled, threshold) if e.row in targets} grouped = {(f.row, c) for f in violations if len(f.columns) > 1 for c in f.columns} for finding in violations: if len(finding.columns) == 1: c = finding.columns[0] proposed[finding.row, c] = Edit(finding.row, c, 0, len(rows[finding.row+1][c]), '', finding.category) else: audit.append({'row':finding.row, 'column':finding.columns[-1], 'category':finding.category, 'status':'relation contradiction: ambiguous faulty member; unchanged'}) columns = element.findall('column') metrics = read_json(checkpoint / 'metrics.json') calibration = mapping(mapping(metrics.get('validation', {})).get('perplexity_calibration', {})) max_perplexity = number(calibration.get('threshold', 0.0)) for (r, c), proposal in proposed.items(): value = rows[r+1][c] entry: dict[str, object] = dict(asdict(proposal)) if (r, c) in grouped: entry['status'] = 'abstained: relation does not identify faulty member' audit.append(entry) continue replacement = deterministic_repair(value, columns[c]) route = 'deterministic' if replacement is None and enabled != set(CATEGORIES): entry['status'] = 'abstained: whole-cell model cannot guarantee disabled categories remain unchanged' audit.append(entry) continue if replacement is None: route = 'model' full = Edit(r, c, 0, len(value), '', proposal.category) replacement = generate_repair(model, consumer, prepared, full, device) if replacement is not None: score = conditional_perplexity(model, consumer, prepared, full, replacement, device) entry['perplexity'] = score if score > max_perplexity: replacement = None entry['status'] = 'abstained: perplexity exceeds validation threshold' if replacement is not None: candidate_xml = replace_cell_xml(xml, r, c, replacement) remaining = [f for f in deterministic_findings(candidate_xml) if f.status == 'invalid' and f.row == r and c in f.columns and f.category in enabled] if remaining: entry['status'] = 'abstained: output violates a declared rule' else: result[r+1][c] = replacement entry['status'] = 'preserved' if replacement == value else 'applied' else: entry.setdefault('status', 'abstained: no terminated answer') entry.update(route=route, replacement=replacement) audit.append(entry) proposals = list(proposed.values()) print(json.dumps({'processed_rows':min(n,start+12),'detections':len(proposals)}),flush=True) if device.type=='cuda': torch.cuda.synchronize() elapsed=time.perf_counter()-begin write_csv(output/'corrected.csv',result) # Reference first becomes available after all inference and application decisions. reference=read_csv(reference_path) if reference_path else None if reference is not None and (reference[0]!=rows[0] or len(reference)!=len(rows)): raise ValueError('Reference shape/header mismatch') stats: dict[str,object]={'device':str(device),'rows':n,'cells':n*len(rows[0]),'threshold':threshold,'load_seconds':round(load_seconds,2),'inference_seconds':round(elapsed,2),'cells_per_second':round(n*len(rows[0])/elapsed,2),'peak_gpu_bytes':torch.cuda.max_memory_allocated() if device.type=='cuda' else None,'detected_spans':len(audit),'changed_cells':sum(a!=b for x,y in zip(rows[1:],result[1:]) for a,b in zip(x,y))} if reference: triples=[(a,b,c) for x,y,z in zip(rows[1:],result[1:],reference[1:]) for a,b,c in zip(x,y,z)] stats.update(initial_faulty_cells=sum(a!=c for a,_b,c in triples),exactly_repaired_cells=sum(a!=c and b==c for a,b,c in triples),remaining_faulty_cells=sum(b!=c for _a,b,c in triples),valid_cells_changed=sum(a==c and b!=c for a,b,c in triples),valid_cells=sum(a==c for a,_b,c in triples)) (output/'audit.json').write_text(json.dumps({'metrics':stats,'proposals':audit},ensure_ascii=False,indent=2)) (output/'review.html').write_text(review(rows,result,reference,audit,stats)) print(json.dumps(stats),flush=True) if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('csv',type=Path) parser.add_argument('--checkpoint',type=Path,required=True) parser.add_argument('--output',type=Path,required=True) parser.add_argument('--schema',type=Path) parser.add_argument('--reference',type=Path) parser.add_argument('--threshold',type=float,default=.9) parser.add_argument('--categories',nargs='+',choices=CATEGORIES,default=CATEGORIES) args=cast(dict[str, object], vars(parser.parse_args())) run(cast(Path,args['csv']),cast(Path,args['checkpoint']),cast(Path,args['output']), cast(Path | None,args['schema']),cast(Path | None,args['reference']), cast(float,args['threshold']),set(cast(list[str],args['categories'])))