Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
108 commits
Select commit Hold shift + click to select a range
2ac63cc
docs(tally): four findings left open when #306 was merged too early
Sep 11, 2026
22990f8
docs(tally): directional case fold, NFC withdrawal reaches every site
Sep 12, 2026
e8d2bad
docs(tally): scope the name fold to its baseline, keep the one proven…
Sep 12, 2026
0f3710d
docs(tally): one name-matching rule per step, and let it be the gated…
Sep 12, 2026
3ba9a43
docs(tally): narrow the 9.8 exception to the case 9.8 actually measured
Sep 12, 2026
96fce0f
docs(tally): withdraw a licence claim I inferred rather than measured
Sep 12, 2026
32f5613
docs(tally): exact-only is the right rule for binding and the wrong o…
Sep 12, 2026
3f7e432
docs(tally): a detector must be wider than a binder, not the same rul…
Sep 12, 2026
29a00c0
docs(tally): the guard ran after the write it was meant to prevent
Sep 12, 2026
230f93b
fix(scripts): a short run of X is data, and decode the capture as UTF-8
Sep 11, 2026
3c15eec
fix(scripts): classify the masking shape per token, not per character
Sep 12, 2026
6357f73
fix(scripts): honour both mask shapes the parsers recognise
Sep 12, 2026
8405c0c
Rectify master-create prerequisites and identity policy scope
Sep 12, 2026
df3e8da
Keep unknown-outcome recovery read-only across active guidance
Sep 12, 2026
0fd62ba
docs(tally): keep unknown outcomes out of dispatchable state
Sep 12, 2026
d7c7a89
Merge commit '0fd62bafe99ff5fa1b540af74effc8e362a0b51d' into tapish-c…
Sep 12, 2026
d88ed55
fix(scripts): bind short masks to captured IMPS field context
Sep 12, 2026
a1d155e
docs(tally): require intent fields before binding existing masters
Sep 12, 2026
743af4a
fix(scripts): withhold mask role when capture context is omitted
Sep 12, 2026
18d8b72
fix(scripts): retain mask context independently of table furniture
Sep 12, 2026
4efd8db
docs(tally): align scoped qualification and held recovery across plans
Sep 12, 2026
3e23b3d
docs: keep slash matching directional
Sep 12, 2026
a26d985
docs: limit Silver slash comparison direction
Sep 12, 2026
b7b5a03
Merge commit 'a26d98586d18a125b06ba318bdd4b0a1d9c11334' into tapish-c…
Sep 12, 2026
6e017d2
docs(tally): four findings left open when #306 was merged too early
Sep 11, 2026
bc36768
docs(tally): directional case fold, NFC withdrawal reaches every site
Sep 12, 2026
9f228ee
docs(tally): scope the name fold to its baseline, keep the one proven…
Sep 12, 2026
e49fc5e
docs(tally): one name-matching rule per step, and let it be the gated…
Sep 12, 2026
3067545
docs(tally): narrow the 9.8 exception to the case 9.8 actually measured
Sep 12, 2026
36342e1
docs(tally): withdraw a licence claim I inferred rather than measured
Sep 12, 2026
67ea110
docs(tally): exact-only is the right rule for binding and the wrong o…
Sep 12, 2026
1e1cbc1
docs(tally): a detector must be wider than a binder, not the same rul…
Sep 12, 2026
f6e12a9
docs(tally): the guard ran after the write it was meant to prevent
Sep 12, 2026
2c6bcf9
docs(tally): a measured row is directional, and its reverse is not me…
Sep 12, 2026
cf58441
chore(tally): reseal the surface after rebasing onto #310
Sep 12, 2026
da4f22d
fix(scripts): a short run of X is data, and decode the capture as UTF-8
Sep 11, 2026
f3141cf
fix(scripts): classify the masking shape per token, not per character
Sep 12, 2026
0693b87
fix(scripts): honour both mask shapes the parsers recognise
Sep 12, 2026
d1ace7e
fix(scripts): the short mask is an IMPS convention, not a global one
Sep 12, 2026
c0ea05b
docs(tally): a fold belongs at lookup, never in the key — and the rac…
Sep 12, 2026
dee1f08
fix(scripts): a short run of X is data, and decode the capture as UTF-8
Sep 11, 2026
c87a978
fix(scripts): classify the masking shape per token, not per character
Sep 12, 2026
214e196
fix(scripts): honour both mask shapes the parsers recognise
Sep 12, 2026
2e7ccb7
fix(scripts): the short mask is an IMPS convention, not a global one
Sep 12, 2026
f49eb42
fix(scripts): the mask is a position in the narration, not a word con…
Sep 12, 2026
98ec2c6
Merge commit 'b4c029d6f525badcca466ea9ee4a54e52e22677e' into tapish-c…
Sep 12, 2026
d2fb266
Merge commit 'cf58441b99f85f50693162ae2f014a7903510f4e' into tapish-c…
Sep 12, 2026
f023e92
chore(tally): reseal merged 314 surface
Sep 12, 2026
dc8cb80
Merge commit 'f023e929' into tapish-codex/rectify-pr316-20260912
Sep 12, 2026
5ae1a66
Merge commit 'd1ace7e4205a8ec45a903fb72becd490389f0621' into tapish-c…
Sep 12, 2026
2a8236c
docs(tally): four findings left open when #306 was merged too early
Sep 11, 2026
0e7add7
docs(tally): directional case fold, NFC withdrawal reaches every site
Sep 12, 2026
e470315
docs(tally): scope the name fold to its baseline, keep the one proven…
Sep 12, 2026
dd8c4e6
docs(tally): one name-matching rule per step, and let it be the gated…
Sep 12, 2026
791ee1b
docs(tally): narrow the 9.8 exception to the case 9.8 actually measured
Sep 12, 2026
3127c77
docs(tally): withdraw a licence claim I inferred rather than measured
Sep 12, 2026
9287a32
docs(tally): exact-only is the right rule for binding and the wrong o…
Sep 12, 2026
9a37785
docs(tally): a detector must be wider than a binder, not the same rul…
Sep 12, 2026
c67632f
docs(tally): the guard ran after the write it was meant to prevent
Sep 12, 2026
f71e661
docs(tally): a measured row is directional, and its reverse is not me…
Sep 12, 2026
d867914
docs(tally): a fold belongs at lookup, never in the key — and the rac…
Sep 12, 2026
e6541f3
chore(tally): reseal after rebasing onto master
Sep 12, 2026
9a42063
fix(scripts): a short run of X is data, and decode the capture as UTF-8
Sep 11, 2026
7311eb8
fix(scripts): classify the masking shape per token, not per character
Sep 12, 2026
eadb1a7
fix(scripts): honour both mask shapes the parsers recognise
Sep 12, 2026
67a7c24
fix(scripts): the short mask is an IMPS convention, not a global one
Sep 12, 2026
a58f211
fix(scripts): the mask is a position in the narration, not a word con…
Sep 12, 2026
396e3b7
Merge commit 'c0ea05b91d034ceed64e4d5da5fe929442d61d23' into tapish-c…
Sep 12, 2026
d3bc949
Merge commit '396e3b7b' into tapish-codex/rectify-pr316-20260912
Sep 12, 2026
cc0a7b5
Merge commit 'f49eb426b541626e1b1ca2955e97a2f2d15dd3ac' into tapish-c…
Sep 12, 2026
26965c7
fix(scripts): stop mirroring a context-sensitive rule from a context-…
Sep 12, 2026
ed632dc
docs(tally): do not prescribe a recovery that does not exist
Sep 12, 2026
48c8248
fix(scripts): a short run of X is data, and decode the capture as UTF-8
Sep 11, 2026
315f56c
fix(scripts): classify the masking shape per token, not per character
Sep 12, 2026
476c683
fix(scripts): honour both mask shapes the parsers recognise
Sep 12, 2026
455ce7d
fix(scripts): the short mask is an IMPS convention, not a global one
Sep 12, 2026
cf92d5d
fix(scripts): the mask is a position in the narration, not a word con…
Sep 12, 2026
e39c0cb
fix(scripts): stop mirroring a context-sensitive rule from a context-…
Sep 12, 2026
52526b8
Merge branch 'tapish-codex/preserve314-e654-20260912' into tapish-cod…
Sep 12, 2026
c5f4a48
Merge commit 'aa859e91714714a7461826b87ddbfd217e5b2475' into tapish-c…
Sep 12, 2026
0c47462
Merge commit '26965c7e46eec962991de8d1697afdc10d310879' into tapish-c…
Sep 12, 2026
24f7a62
Merge commit 'c5f4a4823b5fa697484491c812aec5490508ed95' into tapish-c…
Sep 12, 2026
12016bc
test: record conservative short-mask capture behavior
Sep 12, 2026
9e528d2
Reconcile measured provenance and unavailable create pre-images
Sep 12, 2026
395134d
Preserve parallel sanitizer history with reconciled guard documentation
Sep 12, 2026
ba1e994
Merge commit '9e528d2d' into tapish-codex/rectify-pr316-20260912
Sep 12, 2026
ea3819b
Scope encoding observation to the provenance actually retained
Sep 12, 2026
a29221b
Separate mask allocation cursors by their free positions
Sep 12, 2026
f3bbed7
Merge commit 'ea3819b2114e937615c0b15ec99e8e5d18abcfc2' into tapish-c…
Sep 12, 2026
1e80961
docs(tally): a pre-read is a check, not a mutation-time guarantee
Sep 12, 2026
af4727e
Reconcile policy location and withdraw stale replay acceptance
Sep 12, 2026
4686a88
Merge current master and corrected recovery guidance
Sep 12, 2026
2ff3256
fix(sanitiser): gate generated pages through selected parser
Sep 12, 2026
b4ab300
fix(sanitiser): reject uncertain party evidence
Sep 12, 2026
75fe69b
Require read-only Journal outcome recovery in agent guidance
Sep 12, 2026
384a3d7
test(sanitiser): classify parser evidence refusals
Sep 12, 2026
d4e3368
fix(sanitiser): preserve parser evidence sides
Sep 12, 2026
fa82c39
Retain parser context on sanitizer partition refusals
Sep 12, 2026
1a173e2
Merge reviewed Journal guidance prerequisite
Sep 12, 2026
f0b59fc
Validate sanitized bank evidence boundaries
Sep 12, 2026
da2c024
Enforce importer grammar in sanitizer evidence
Sep 12, 2026
8383e5c
docs: retain measured directional matches within their qualified scope
Sep 12, 2026
d7a6c06
Merge commit '8383e5c3fef8b407236650d00e5fdcb932f9c20c' into tapish-c…
Sep 12, 2026
6bfe2d2
docs: align the implementation guide with scoped binding authority
Sep 12, 2026
50ca5af
Merge commit '6bfe2d26a7ca28028b58668046ec9967110353ea' into tapish-c…
Sep 12, 2026
bb5b38b
docs: scope directional slash comparison
Sep 12, 2026
ab5dc08
Merge clarified directional plan from prerequisite
Sep 12, 2026
1df8132
Merge master into rectify-issue330 after #314, #316 and #331 landed
Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions scripts/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ Read the banner comment at the top of each file for exactly what is real and wha
```bash
pdftotext -bbox-layout -opw "$PASSWORD" statement.pdf raw.xml
python3 scripts/sanitise-bbox-capture.py raw.xml \
scripts/fixtures/hdfc-bbox-capture.xml "HDFC current-account" \
scripts/fixtures/hdfc-bbox-capture.xml hdfc \
0:0-800 2:200-330,700-800 3:200-300
```

```bash
python3 scripts/sanitise-bbox-capture.py raw.xml \
scripts/fixtures/sbi-bbox-capture.xml "State Bank of India current-account" \
scripts/fixtures/sbi-bbox-capture.xml sbi \
0:90-741 1:0-165
```

Expand All @@ -30,6 +30,11 @@ header, a continuation page that repeats the column header while a row is in pro
carries the end-of-statement marker, and — for HDFC — one page *after* that marker, so the marker
cannot be removed without a test noticing.

The bank argument is a closed parser selection (`hdfc` or `sbi`). The sanitiser parses every selected
source region and the complete generated regions, using the same retained geometry before writing the destination. It refuses empty,
incomplete accounting rows, misaligned or party-class-changing evidence; captured geometry remains fixture evidence and does not
qualify raw customer data.

## Adding a capture for a new bank

1. Sanitise, then **diff the result against the source** and scan for surviving tokens (the script's
Expand Down
114 changes: 108 additions & 6 deletions scripts/sanitise-bbox-capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
substituted, so a value that was never anticipated is fabricated by default
rather than kept by default.
"""
import decimal
import importlib.util
import re, sys, pathlib

TEMPLATE = set("""
Expand Down Expand Up @@ -43,6 +45,16 @@
_dates = {}


class EvidenceRefusal(SystemExit):
"""A stable reason, with optional non-sensitive parser location context."""
def __init__(self, category, bank=None, row_index=None):
self.category, self.bank, self.row_index = category, bank, row_index
context = "" if bank is None else f" bank={bank}"
if row_index is not None:
context += f" row={row_index}"
super().__init__(f"sanitise: {category}{context}")


def _fake_date(token):
"""Dates are remapped, not digit-substituted.

Expand Down Expand Up @@ -491,6 +503,89 @@ def _kept_words(pages, keep):
yield head, words


def _load_parser(bank_name):
"""Load one of the parsers used to qualify a generated fixture."""
if bank_name not in ("hdfc", "sbi"):
raise EvidenceRefusal("unsupported_parser_profile")
path = pathlib.Path(__file__).with_name("bank_statement_import.py")
spec = importlib.util.spec_from_file_location("sanitise_bank_parser", path)
if spec is None or spec.loader is None:
raise EvidenceRefusal("parser_unavailable")
parser = importlib.util.module_from_spec(spec)
spec.loader.exec_module(parser)
return parser, parser.BANKS[bank_name]()


def _assert_party_partition(source_keys, output_keys, bank_name):
"""Require a two-way, one-to-one mapping of party equivalence classes."""
if not source_keys or len(source_keys) != len(output_keys):
raise EvidenceRefusal("party_evidence_empty_or_misaligned", bank_name)
source_to_output, output_to_source = {}, {}
for index, (source, output) in enumerate(zip(source_keys, output_keys)):
if (not source or not output
or (isinstance(source, str) and source.upper() in ("UNRESOLVED", "UNNAMED"))
or (isinstance(output, str) and output.upper() in ("UNRESOLVED", "UNNAMED"))):
raise EvidenceRefusal("party_evidence_underdetermined", bank_name, index)
old = source_to_output.setdefault(source, output)
reverse = output_to_source.setdefault(output, source)
if old != output:
raise EvidenceRefusal("party_partition_split", bank_name, index)
if reverse != source:
raise EvidenceRefusal("party_partition_merged", bank_name, index)


def _validate_parser_evidence(parser, bank, source_pages, output_pages, bank_name):
"""Parse complete page sets and compare only structure preserved by scrubbing."""
try:
source_rows = parser.parse_pages(source_pages, bank)
output_rows = parser.parse_pages(output_pages, bank)
except EvidenceRefusal:
raise
except SystemExit as error:
raise EvidenceRefusal("parser_evidence_invalid", bank_name) from error
except (KeyError, IndexError, TypeError, ValueError, decimal.InvalidOperation) as error:
raise EvidenceRefusal("parser_evidence_invalid", bank_name) from error
if not source_rows or not output_rows or len(source_rows) != len(output_rows):
raise EvidenceRefusal("parser_evidence_empty_or_misaligned", bank_name)

source_dates, output_dates = [], []
source_keys, output_keys = [], []
for index, (source, output) in enumerate(zip(source_rows, output_rows)):
try:
source_date = str(source[bank.date_column]).strip()
output_date = str(output[bank.date_column]).strip()
source_dates.append(bank.parse_date(source_date))
output_dates.append(bank.parse_date(output_date))
for row in (source, output):
for column in (bank.debit_column, bank.credit_column):
value = str(row.get(column) or "").strip()
if value:
parser._money(value, column, index + 1)
balance = str(row.get(bank.balance_column) or "").strip()
parser._balance(balance, bank.balance_column, index + 1)
source_ref = bank.reference(source)
output_ref = bank.reference(output)
source_side = tuple(bool(source.get(column)) for column in (bank.debit_column, bank.credit_column))
output_side = tuple(bool(output.get(column)) for column in (bank.debit_column, bank.credit_column))
source_shape = (source_side, bool(source.get(bank.balance_column)), source_ref[0], len(str(source_ref[1])))
output_shape = (output_side, bool(output.get(bank.balance_column)), output_ref[0], len(str(output_ref[1])))
if sum(source_side) != 1 or not source_shape[1] or sum(output_side) != 1 or not output_shape[1]:
raise EvidenceRefusal("accounting_row_incomplete", bank_name, index)
if source_shape != output_shape:
raise EvidenceRefusal("accounting_row_shape_misaligned", bank_name, index)
source_keys.append(parser._key(bank.party(source)))
output_keys.append(parser._key(bank.party(output)))
except EvidenceRefusal:
raise
except SystemExit as error:
raise EvidenceRefusal("row_alignment_invalid", bank_name, index) from error
except Exception as error:
raise EvidenceRefusal("row_alignment_invalid", bank_name, index) from error

_assert_party_partition(source_dates, output_dates, bank_name)
_assert_party_partition(source_keys, output_keys, bank_name)


def main(source, destination, keep, bank):
"""keep: [(page_index, [(y_min, y_max), ...]), ...] regions to retain."""
# `pdftotext` emits UTF-8. `read_text()` without an encoding decodes with
Expand All @@ -499,7 +594,9 @@ def main(source, destination, keep, bank):
# `UnicodeDecodeError` before sanitisation runs at all. Neither CI nor the
# unit cases reach this boundary: CI is ubuntu-only, and the Unicode tests
# call `_scrub_plain` with strings that are already decoded.
keep = list(keep)
pages = pathlib.Path(source).read_text(encoding="utf-8").split("<page ")[1:]
parser, bank_profile = _load_parser(bank)
regions = list(_kept_words(pages, keep))
# Two passes, and the first one has to be complete before the second starts.
# A replacement is only safe once the allocator knows every token the
Expand All @@ -509,15 +606,20 @@ def main(source, destination, keep, bank):
for _, words in regions:
for *_, body in words:
reserve_source_tokens(body)
chunks = [
def render(transform):
return [
"<page " + head + "\n"
+ "\n".join(f'<word xMin="{x0}" yMin="{y0}" xMax="{x1}" yMax="{y1}">'
f'{scrub(body)}</word>' for x0, y0, x1, y1, body in words)
f'{transform(body)}</word>' for x0, y0, x1, y1, body in words)
+ "\n</page>"
for head, words in regions
]
pathlib.Path(destination).write_text(
BANNER_TEMPLATE.format(bank=bank) + "\n".join(chunks) + "\n", encoding="utf-8")
]
source_chunks = render(lambda body: body)
chunks = render(scrub)
output = BANNER_TEMPLATE.format(bank=bank_profile.name) + "\n".join(chunks) + "\n"
_validate_parser_evidence(parser, bank_profile, [chunk[len("<page "):] for chunk in source_chunks],
output.split("<page ")[1:], bank_profile.name)
pathlib.Path(destination).write_text(output, encoding="utf-8")
print(f"wrote {destination}: "
f"{sum(chunk.count('<word') for chunk in chunks)} words, {len(chunks)} pages")

Expand All @@ -526,7 +628,7 @@ def main(source, destination, keep, bank):

SOURCE pdftotext -bbox-layout output from a real statement
DEST fixture to write
BANK a description for the banner, e.g. "HDFC current-account"
BANK parser profile: hdfc or sbi (closed selection)
PAGE:.. 0-based page index and the y ranges to keep from it

Always diff the result against the source before committing it, and scan the
Expand Down
Loading