womblex 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. womblex-0.2.0/CHANGELOG.md +1093 -0
  2. womblex-0.2.0/LICENSE +201 -0
  3. womblex-0.2.0/MANIFEST.in +29 -0
  4. womblex-0.2.0/PKG-INFO +434 -0
  5. womblex-0.2.0/README.md +379 -0
  6. womblex-0.2.0/pyproject.toml +154 -0
  7. womblex-0.2.0/setup.cfg +4 -0
  8. womblex-0.2.0/src/womblex/__init__.py +3 -0
  9. womblex-0.2.0/src/womblex/_models/README.md +42 -0
  10. womblex-0.2.0/src/womblex/_models/en_AU/LICENSE +347 -0
  11. womblex-0.2.0/src/womblex/_models/en_AU/index.aff +205 -0
  12. womblex-0.2.0/src/womblex/_models/en_AU/index.dic +49824 -0
  13. womblex-0.2.0/src/womblex/_models/kanon-2-tokenizer/merges.txt +50108 -0
  14. womblex-0.2.0/src/womblex/_models/kanon-2-tokenizer/tokenizer.json +250913 -0
  15. womblex-0.2.0/src/womblex/_models/kanon-2-tokenizer/tokenizer_config.json +57 -0
  16. womblex-0.2.0/src/womblex/_models/kanon-2-tokenizer/vocab.json +50370 -0
  17. womblex-0.2.0/src/womblex/analyse/__init__.py +1 -0
  18. womblex-0.2.0/src/womblex/analyse/embed.py +84 -0
  19. womblex-0.2.0/src/womblex/analyse/embed_stage.py +132 -0
  20. womblex-0.2.0/src/womblex/analyse/enrich.py +435 -0
  21. womblex-0.2.0/src/womblex/analyse/enrich_merge.py +184 -0
  22. womblex-0.2.0/src/womblex/analyse/enrich_stage.py +364 -0
  23. womblex-0.2.0/src/womblex/analyse/graph.py +504 -0
  24. womblex-0.2.0/src/womblex/analyse/graph_refresh.py +231 -0
  25. womblex-0.2.0/src/womblex/analyse/models.py +200 -0
  26. womblex-0.2.0/src/womblex/analyse/query.py +122 -0
  27. womblex-0.2.0/src/womblex/batch.py +77 -0
  28. womblex-0.2.0/src/womblex/cli/__init__.py +93 -0
  29. womblex-0.2.0/src/womblex/cli/__main__.py +10 -0
  30. womblex-0.2.0/src/womblex/cli/_shared.py +71 -0
  31. womblex-0.2.0/src/womblex/cli/cloud.py +271 -0
  32. womblex-0.2.0/src/womblex/cli/embed.py +93 -0
  33. womblex-0.2.0/src/womblex/cli/ingest.py +123 -0
  34. womblex-0.2.0/src/womblex/cli/link.py +248 -0
  35. womblex-0.2.0/src/womblex/cli/normalise.py +92 -0
  36. womblex-0.2.0/src/womblex/cli/pii.py +99 -0
  37. womblex-0.2.0/src/womblex/cli/pipeline.py +490 -0
  38. womblex-0.2.0/src/womblex/cli/profile.py +76 -0
  39. womblex-0.2.0/src/womblex/cli/quality.py +72 -0
  40. womblex-0.2.0/src/womblex/cli/redact.py +290 -0
  41. womblex-0.2.0/src/womblex/cli/score.py +83 -0
  42. womblex-0.2.0/src/womblex/cli/spellfix.py +90 -0
  43. womblex-0.2.0/src/womblex/cli/verify.py +100 -0
  44. womblex-0.2.0/src/womblex/cloud/__init__.py +21 -0
  45. womblex-0.2.0/src/womblex/cloud/queue.py +220 -0
  46. womblex-0.2.0/src/womblex/cloud/worker.py +116 -0
  47. womblex-0.2.0/src/womblex/config.py +778 -0
  48. womblex-0.2.0/src/womblex/ingest/__init__.py +1 -0
  49. womblex-0.2.0/src/womblex/ingest/abn_bulk.py +346 -0
  50. womblex-0.2.0/src/womblex/ingest/detect.py +599 -0
  51. womblex-0.2.0/src/womblex/ingest/elements.py +154 -0
  52. womblex-0.2.0/src/womblex/ingest/extract.py +555 -0
  53. womblex-0.2.0/src/womblex/ingest/forms.py +254 -0
  54. womblex-0.2.0/src/womblex/ingest/geospatial.py +169 -0
  55. womblex-0.2.0/src/womblex/ingest/gnaf.py +189 -0
  56. womblex-0.2.0/src/womblex/ingest/gnaf_schema.py +131 -0
  57. womblex-0.2.0/src/womblex/ingest/grid_projection.py +255 -0
  58. womblex-0.2.0/src/womblex/ingest/heuristics_cv2.py +282 -0
  59. womblex-0.2.0/src/womblex/ingest/heuristics_numpy.py +149 -0
  60. womblex-0.2.0/src/womblex/ingest/interfaces/__init__.py +23 -0
  61. womblex-0.2.0/src/womblex/ingest/interfaces/protocols.py +124 -0
  62. womblex-0.2.0/src/womblex/ingest/llm_ocr.py +175 -0
  63. womblex-0.2.0/src/womblex/ingest/morphology.py +244 -0
  64. womblex-0.2.0/src/womblex/ingest/orchestrator.py +493 -0
  65. womblex-0.2.0/src/womblex/ingest/paddle_ocr.py +492 -0
  66. womblex-0.2.0/src/womblex/ingest/page_profile.py +222 -0
  67. womblex-0.2.0/src/womblex/ingest/records.py +264 -0
  68. womblex-0.2.0/src/womblex/ingest/redaction.py +5 -0
  69. womblex-0.2.0/src/womblex/ingest/spreadsheet.py +354 -0
  70. womblex-0.2.0/src/womblex/ingest/spreadsheet_print.py +505 -0
  71. womblex-0.2.0/src/womblex/ingest/strategies.py +21 -0
  72. womblex-0.2.0/src/womblex/ingest/strategies_file.py +173 -0
  73. womblex-0.2.0/src/womblex/ingest/strategies_scanned.py +562 -0
  74. womblex-0.2.0/src/womblex/ingest/views.py +273 -0
  75. womblex-0.2.0/src/womblex/link/__init__.py +22 -0
  76. womblex-0.2.0/src/womblex/link/matcher.py +169 -0
  77. womblex-0.2.0/src/womblex/link/normalise.py +59 -0
  78. womblex-0.2.0/src/womblex/link/reference.py +100 -0
  79. womblex-0.2.0/src/womblex/link/stage.py +180 -0
  80. womblex-0.2.0/src/womblex/operations/__init__.py +44 -0
  81. womblex-0.2.0/src/womblex/operations/chunk.py +95 -0
  82. womblex-0.2.0/src/womblex/operations/enrich.py +89 -0
  83. womblex-0.2.0/src/womblex/operations/extract.py +86 -0
  84. womblex-0.2.0/src/womblex/operations/models.py +56 -0
  85. womblex-0.2.0/src/womblex/operations/persist.py +71 -0
  86. womblex-0.2.0/src/womblex/operations/pii.py +104 -0
  87. womblex-0.2.0/src/womblex/operations/redact.py +77 -0
  88. womblex-0.2.0/src/womblex/pii/__init__.py +13 -0
  89. womblex-0.2.0/src/womblex/pii/cleaner.py +419 -0
  90. womblex-0.2.0/src/womblex/pii/pii_stage.py +239 -0
  91. womblex-0.2.0/src/womblex/pii/stage.py +162 -0
  92. womblex-0.2.0/src/womblex/process/__init__.py +1 -0
  93. womblex-0.2.0/src/womblex/process/chunk_stage.py +398 -0
  94. womblex-0.2.0/src/womblex/process/chunker.py +528 -0
  95. womblex-0.2.0/src/womblex/process/normalise.py +196 -0
  96. womblex-0.2.0/src/womblex/process/normalise_stage.py +141 -0
  97. womblex-0.2.0/src/womblex/process/quality.py +135 -0
  98. womblex-0.2.0/src/womblex/process/quality_stage.py +112 -0
  99. womblex-0.2.0/src/womblex/process/spellfix.py +194 -0
  100. womblex-0.2.0/src/womblex/process/spellfix_stage.py +158 -0
  101. womblex-0.2.0/src/womblex/process/text_overlay.py +86 -0
  102. womblex-0.2.0/src/womblex/profile/__init__.py +19 -0
  103. womblex-0.2.0/src/womblex/profile/columns.py +325 -0
  104. womblex-0.2.0/src/womblex/py.typed +0 -0
  105. womblex-0.2.0/src/womblex/redact/__init__.py +9 -0
  106. womblex-0.2.0/src/womblex/redact/batch.py +316 -0
  107. womblex-0.2.0/src/womblex/redact/detector.py +142 -0
  108. womblex-0.2.0/src/womblex/redact/stage.py +303 -0
  109. womblex-0.2.0/src/womblex/redact/utils.py +28 -0
  110. womblex-0.2.0/src/womblex/score.py +281 -0
  111. womblex-0.2.0/src/womblex/store/__init__.py +6 -0
  112. womblex-0.2.0/src/womblex/store/checkpoint.py +130 -0
  113. womblex-0.2.0/src/womblex/store/enrichment_doc.py +102 -0
  114. womblex-0.2.0/src/womblex/store/enrichment_output.py +471 -0
  115. womblex-0.2.0/src/womblex/store/normalise_output.py +94 -0
  116. womblex-0.2.0/src/womblex/store/output.py +728 -0
  117. womblex-0.2.0/src/womblex/store/pii_output.py +162 -0
  118. womblex-0.2.0/src/womblex/store/provenance_output.py +133 -0
  119. womblex-0.2.0/src/womblex/store/quality_output.py +88 -0
  120. womblex-0.2.0/src/womblex/store/register_manifest.py +114 -0
  121. womblex-0.2.0/src/womblex/store/remote.py +144 -0
  122. womblex-0.2.0/src/womblex/store/retention.py +120 -0
  123. womblex-0.2.0/src/womblex/store/run_manifest.py +46 -0
  124. womblex-0.2.0/src/womblex/store/shard_audit.py +701 -0
  125. womblex-0.2.0/src/womblex/store/spellfix_output.py +131 -0
  126. womblex-0.2.0/src/womblex/utils/__init__.py +0 -0
  127. womblex-0.2.0/src/womblex/utils/availability.py +25 -0
  128. womblex-0.2.0/src/womblex/utils/checksum.py +17 -0
  129. womblex-0.2.0/src/womblex/utils/metrics.py +358 -0
  130. womblex-0.2.0/src/womblex/utils/models.py +85 -0
  131. womblex-0.2.0/src/womblex/utils/tabular_metrics.py +209 -0
  132. womblex-0.2.0/src/womblex/utils/token_packer.py +269 -0
  133. womblex-0.2.0/src/womblex/verify/__init__.py +5 -0
  134. womblex-0.2.0/src/womblex/verify/engine.py +230 -0
  135. womblex-0.2.0/src/womblex.egg-info/PKG-INFO +434 -0
  136. womblex-0.2.0/src/womblex.egg-info/SOURCES.txt +138 -0
  137. womblex-0.2.0/src/womblex.egg-info/dependency_links.txt +1 -0
  138. womblex-0.2.0/src/womblex.egg-info/entry_points.txt +2 -0
  139. womblex-0.2.0/src/womblex.egg-info/requires.txt +34 -0
  140. womblex-0.2.0/src/womblex.egg-info/top_level.txt +1 -0
@@ -0,0 +1,1093 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-07-19
11
+
12
+ ### Added
13
+ - **Pre-extracted records ingest (`ingest/records.py`).** Turns already-clean
14
+ text records (a JSONL corpus; the Open Australian Legal Corpus) straight into
15
+ the standard element-shard layout (`*.elements.parquet` + sidecars +
16
+ `*._manifest.parquet`) so the `enrich → chunk → embed → graph-refresh`
17
+ pipeline runs over a pre-extracted corpus unchanged — unlike the register
18
+ ingests (`gnaf`/`abn`/`geo`) which *bypass* the NLP pipeline, this one *feeds*
19
+ it. `source_hash = sha256(record_id + text)` is content-addressed (unchanged
20
+ records are cache hits on re-ingest); text is split into paragraph blocks so
21
+ the reassembled narrative round-trips. Corpus-agnostic — a
22
+ `RecordFieldMapping` (declared by a thin `stories/<corpus>` config) names the
23
+ id / text / provenance fields. Record metadata lands in a
24
+ `*.provenance.parquet` sidecar (`store/provenance_output.py`) consolidated
25
+ into a run-root `manifest.parquet` (source_hash → provenance).
26
+ - **Token-budget request packer (`utils/token_packer.py`).** Isaacus rate
27
+ limits bind on *tokens per request/window*, not request count, so requests
28
+ are packed by exact local token counts from the kanon-2 tokenizer:
29
+ `pack_by_tokens` groups items to `min(max_items, token_budget)`; an
30
+ over-budget item is sent solo; `split_on_boundaries` splits an over-ceiling
31
+ document on blank-line boundaries into offset-tagged segments. `TokenCounter`
32
+ is a cached, offline wrapper over the tokenizer.
33
+ - **Enrichment — token-aware batching + long-doc split (`enrich_stage.py`).**
34
+ Replaces the one-doc-per-call loop with packer-driven requests of
35
+ `min(max_texts_per_request=8, token_budget)` (8× fewer requests for small
36
+ docs; token-aware so a batch of long judgments never overpacks a
37
+ 429-triggering request). A doc over `split_ceiling` is split and its
38
+ per-segment results offset-merged (`analyse/enrich_merge.py`). `enrich.py`
39
+ honours a `Retry-After` header on 429. New `EnrichmentConfig` knobs:
40
+ `tokenizer`, `max_texts_per_request`, `token_budget`, `split_ceiling`.
41
+ - **Graph-edge refresh stage (`analyse/graph_refresh.py`, `womblex
42
+ graph-refresh`).** Offline, deterministic rebuild of mention→chunk edges from
43
+ the entity + chunk sidecars (both carry char offsets) — needed because AI
44
+ chunking runs *after* enrichment, so the enrich-time graph has no chunk edges
45
+ yet. Populates `enrichment_entities.chunk_index` and refreshes
46
+ `*.graph_edges.parquet`, preserving hierarchy/citation edges. Idempotent.
47
+ - **Offline kanon-2 tokenizer.** The tokenizer is vendored under
48
+ `_models/kanon-2-tokenizer` and resolved locally by both the token packer and
49
+ `create_chunker` — no Hugging Face round-trip per run, offline-safe.
50
+ - **Distributed / cloud execution (`womblex[cloud]`).** Optional scale-out for
51
+ long batch runs without changing the local CPU-first default. Three pieces:
52
+ (1) `store/remote.py` — an fsspec stage-in/stage-out object-storage adapter
53
+ (S3/MinIO/GCS/local) that confines all remote-storage knowledge to one place
54
+ so the `Path`-based stages stay untouched; (2) `cloud/queue.py` — a Postgres
55
+ `FOR UPDATE SKIP LOCKED` job queue over one `womblex_jobs` table where the row
56
+ `status` *is* the distributed checkpoint (idempotent re-enqueue on
57
+ `(run_id, batch_num)`, per-job retry, crashed-worker requeue); (3)
58
+ `cloud/worker.py` — a worker that claims a batch, stages its inputs, runs the
59
+ shared `batch.process_batch` body, and publishes `batch-NNNN.*.parquet` shards
60
+ back. New CLI: `womblex enqueue` / `worker` / `jobs` / `finalize` (the last
61
+ consolidates a distributed run's shard manifests into
62
+ `<run>/manifest.parquet` in the store — the explicit end-step `womblex run`
63
+ performs locally). Outputs are the ordinary shard layout, so `manifest` /
64
+ `chunk --shards` consume a distributed run exactly like a local one.
65
+ `process_batch` is also now the single shared body behind `womblex run`, so
66
+ local and distributed modes cannot diverge.
67
+ - **Container image + compose stack.** `Dockerfile` (extraction + `[cloud]`)
68
+ and `docker-compose.yml` bundling Postgres (queue), MinIO (object store), and
69
+ horizontally scalable workers (`docker compose up --scale worker=N`).
70
+ - **CI security job.** `ci.yml` gains a `security` job: Semgrep SAST over `src/`
71
+ with the Python + OWASP Top Ten rulesets (blocking) and `pip-audit`
72
+ dependency scanning (informational — the ML dep tree carries advisories we
73
+ can't action directly). The test job now also installs the `cloud` extra so
74
+ the object-storage tests run in CI.
75
+ - **ABN Lookup bulk extract ingest.** New `ingest/abn_bulk.py` stream-parses
76
+ the ABR bulk extract XML files (`yyyymmddPublicNN.xml`, ~6 GB uncompressed
77
+ across 20 files) with constant memory and writes two Parquet files per
78
+ input: `<stem>.parquet` (one row per ABR record — ABN/status, entity type,
79
+ main entity name or legal-entity name parts with given names as separate
80
+ `given_name_1` / `given_name_2` columns since a single given name may
81
+ itself contain a space, state/postcode, ACN, GST) and
82
+ `<stem>_names.parquet` (one row per registered name — main/legal, business,
83
+ trading and DGR fund names keyed by ABN, ready for `link/` register
84
+ consumption). Values are verbatim strings, absent optionals are `""`, and
85
+ provenance (schema version, source file, MD5, row counts) rides as parquet
86
+ metadata — the `ingest/gnaf.py` pattern. Failures are isolated per file:
87
+ any error (malformed XML, read/write failure) logs with the source name,
88
+ removes partial output, and lets the directory ingest continue. New
89
+ `womblex ingest-abn <file|dir>` CLI command; bypasses the NLP pipeline.
90
+ (`ingest/abn_bulk.py`, `cli/ingest.py`, `tests/test_abn_bulk.py` —
91
+ all-synthetic fixtures.) The shared MD5 helper moved to
92
+ `utils/checksum.py`, replacing the per-module copies in `ingest/gnaf.py`
93
+ and `ingest/geospatial.py`.
94
+ - **Spreadsheet preamble/header detection.** Export products that open with
95
+ title rows, generated-date lines or `key: value` metadata blocks above the
96
+ real header (e.g. AusTender contract-notice exports, agency stats
97
+ workbooks) previously had the first row parsed as the header, with pandas
98
+ fabricating `Unnamed: N` column names that landed verbatim-violating cell
99
+ values on the element stream — and ragged CSVs (one-field title row above
100
+ a wide header) failed outright. Sheets are now read with `header=None`
101
+ (CSVs via a new field-count-sniffing `read_csv_raw`, capped at `nrows`
102
+ when sampling) and split via `split_preamble`: the header is the
103
+ candidate row (≥2 non-empty cells in a 10-row window) that starts the
104
+ longest run of table-consistent rows below it — a blank separator or the
105
+ wider table below breaks a title/metadata row's run, ties prefer the
106
+ wider candidate, single-cell section rows are neutral, and a width-ratio
107
+ rule plus row-0 fallback covers header-only and single-column sheets.
108
+ Preamble rows land verbatim on the sheet_meta element
109
+ (`meta["preamble"]`) and the row-0-is-header contract of the cell grid is
110
+ preserved for downstream table views. Header-first, single-column and
111
+ uniformly narrow (key/value, glossary) sheets are unaffected. Detection
112
+ (`_detect_spreadsheet`) shares the same reader and split with headroom so
113
+ the 500-row classification sample is unchanged, and `SheetInfo.key_column`
114
+ resolves against the real header. (`ingest/spreadsheet.py`,
115
+ `ingest/detect.py`.)
116
+ - **Run-level document manifest.** `womblex run` now consolidates the per-batch
117
+ `batch-NNNN._manifest.parquet` sidecars into a single
118
+ `<run_root>/manifest.parquet` at the end of the run — the published
119
+ documents table mapping `source_hash` (the join key on every chunk/sidecar
120
+ row) back to `doc_id`, `filename`, extraction method, counts and status, so
121
+ shipped chunks are attributable to their source documents. A new
122
+ `womblex manifest --shards <dir> [-o PATH]` command regenerates it for
123
+ existing runs. (`store/run_manifest.py`, `cli/pipeline.py`.)
124
+ - **Shippable enrichment graph.** `enrich_shards` now writes a
125
+ `*.graph_edges.parquet` sibling per batch alongside the entities/meta
126
+ sidecars — the Kanon-2 document graph (containment, segment hierarchy,
127
+ person/location hierarchy, citations, cross-references, contact-info and
128
+ date relations) flattened to the existing `GRAPH_EDGE_SCHEMA`, with
129
+ `document_id` carrying the `source_hash` so it joins the other sidecars.
130
+ When the batch already has a `*.chunks.parquet` sibling, narrative chunks
131
+ are mapped in so the graph includes mention→chunk edges. On resume, a batch
132
+ missing its graph sidecar is re-enriched so prior runs gain it (the graph is
133
+ only buildable from the live enrichment result). New
134
+ `write_graph_edges_shard` / `read_graph_edges` / `graph_edges_path_for`.
135
+ (`analyse/enrich_stage.py`, `store/enrichment_output.py`.)
136
+ - **CLI fix — `womblex chunk --shards` + `--config` combinable.** The two
137
+ flags were in a `required=True` mutually exclusive argparse group, which made
138
+ the `--shards` branch's config handling (chunking settings, `chunking_model`
139
+ for AI chunking, `processing.text_source`) unreachable from the CLI —
140
+ per-stage AI chunking was dead-ended. They now combine: `--shards` with
141
+ `--config` sources chunking settings from the YAML; `--config` alone remains
142
+ the E2E composition mode. (`cli/pipeline.py`.)
143
+ - **Single-enrichment reuse for AI chunking.** When AI chunking
144
+ (`chunking.chunking_model`) and the `enrich` stage are both on, the enrich
145
+ stage now persists the raw ILGS Document per doc to a new
146
+ `*.enrichment_doc.parquet` sidecar (opt-in `enrichment.persist_document`,
147
+ auto-enabled by `WomblexConfig`), and the chunk stage reuses it for semchunk's
148
+ AI path instead of re-enriching — eliminating the double Kanon-2 call. Reuse is
149
+ gated by a **byte-identity guard**: a persisted `Document.text` is used only
150
+ when it equals the chunk stage's freshly reassembled narrative for that
151
+ `source_hash`; on any mismatch (different `text_source`, stale/corrupt blob,
152
+ absent sidecar) the doc falls back to self-enrich, so offsets can never desync
153
+ the PII mention↔chunk mapping. Requires running `enrich` before `chunk`; the
154
+ `WomblexConfig` validator now warns about that ordering rather than about
155
+ double-enrichment. New self-contained `store/enrichment_doc.py`;
156
+ `enrich_documents_raw` / `enrich_document_raw` expose the raw SDK Document;
157
+ `chunk_batch` gains `narrative_overrides`. Verified live against
158
+ `kanon-2-enricher` (gates in `docs/decisions.md`). (`store/enrichment_doc.py`,
159
+ `analyse/enrich.py`, `analyse/enrich_stage.py`, `process/chunker.py`,
160
+ `process/chunk_stage.py`, `config.py`, `cli/link.py`.)
161
+ - **AI chunking pass-through (semchunk 4).** `ChunkingConfig.chunking_model`
162
+ (default `null`) enables semchunk 4's AI chunking — chunk boundaries follow
163
+ the Isaacus enricher's (`kanon-2-enricher`) structure spans instead of the
164
+ offline token/recursive split. Opt-in and off by default, so callers using a
165
+ non-Kanon tokeniser keep purely offline chunking (composable). `create_chunker`
166
+ now forwards `chunking_model`, `isaacus_client`, and `tokenizer_kwargs`
167
+ straight to `semchunk.chunkerify` (thin-adapter doctrine — semchunk's params
168
+ are the feature surface); threaded through both the E2E `run_chunking` path and
169
+ the per-stage `chunk_shards`. Graph-reuse across the chunk + enrich stages (so
170
+ the narrative is enriched once, not twice) is now implemented — see
171
+ "Single-enrichment reuse for AI chunking" above. Bumps `semchunk>=3.0` →
172
+ `>=4.0`. (`process/chunker.py`, `config.py`, `process/chunk_stage.py`,
173
+ `operations/chunk.py`.)
174
+ - **`spellfix` stage — dictionary-gated OCR character-confusion repair
175
+ (`womblex spellfix`).** A separate, opt-in cleaning op (distinct from the
176
+ fidelity-neutral `normalise`) that fixes digit/letter glyph confusions
177
+ (`chi1d`→`child`). Validates candidates against the bundled en_AU Hunspell
178
+ dictionary (`spylls`, harvested from the Australian Writing MCP; MIT/SCOWL) and
179
+ rewrites a token only on three gates: out-of-dictionary trigger,
180
+ single-character in-dictionary candidate, and a *unique* such candidate.
181
+ Default Tier A swaps only OCR digit→letter homoglyphs (length-preserving);
182
+ Tier B general edit-distance-1 is opt-in (`--general` / `general_edits`,
183
+ carries proper-noun risk). Repairs at the **element layer** (reads
184
+ `*.elements.parquet`, chaining off the normalise overlay when present) and
185
+ writes a `*.spellfix_text.parquet` element-text overlay + a
186
+ `*.spellfix_corrections.parquet` audit — raw elements untouched. New deps:
187
+ `spylls`; bundled dict under `_models/en_AU`. (`process/spellfix.py`,
188
+ `process/spellfix_stage.py`, `store/spellfix_output.py`, `cli/spellfix.py`,
189
+ `SpellfixConfig`.)
190
+ - **Composable element-text overlays via one `processing.text_source`.** New
191
+ `process/text_overlay.py` resolves the normalise / spellfix element-text layer
192
+ selected by a single pipeline setting (`elements` | `normalised` | `spellfix`)
193
+ and applies it before reassembly at **both** the chunk and enrich sites, so
194
+ chunking, embeddings, Kanon-2 enrichment and PII all consume the same repaired
195
+ text in one offset coordinate space. Deliberately one knob (not per-stage):
196
+ enrichment runs on the whole document and PII maps mention offsets onto chunks
197
+ via `chunk.start_char`, so the enricher input and chunk source must match.
198
+ - **Enricher `overflow_strategy` (default `auto`).** `enrich_documents` /
199
+ `EnrichmentConfig` now pass `overflow_strategy` to `enrichments.create`,
200
+ defaulting to `auto` (vs upstream `null`, which errors on >16k-token inputs).
201
+ Kanon-2 chunks long documents internally and stitches the ILGS graph back into
202
+ a single prediction; returned span offsets still index the full source, so the
203
+ PII offset mapping is unaffected. Fixes long FOI bundles erroring on enrichment.
204
+ - **`score --text-source` — CER of extraction vs normalisation.** `womblex
205
+ score` (and `score_labels`) now accept `text_source={elements,normalised}`:
206
+ `normalised` reassembles the labelled page from the `*.normalised_text.parquet`
207
+ sidecar instead of the verbatim element stream, so a caller can measure how
208
+ the cleanup/normalisation stage changes CER against the same GT.
209
+ - **Benchmark: ACT-ECI labelled-pages raw-vs-normalised CER.** New
210
+ `TestActEciLabelledPages` (`-m benchmark`) extracts each labelled page, scores
211
+ raw extraction and normalise-stage output against the per-page GT, and reports
212
+ a per-strategy `Raw CER / Norm CER / Δ` table in `docs/accuracy/EXTRACTION.md`.
213
+ Degenerate GT (<20 chars) excluded; a regression guard asserts normalisation
214
+ never worsens CER. (Fixtures cohort expanded 7→19 labelled pages.)
215
+ - **`quality` stage — chunk-quality annotation sidecar (`womblex quality`).**
216
+ Reads `*.chunks.parquet` and writes a `*.chunk_quality.parquet` sibling
217
+ (joined on `(source_hash, chunk_index)`) with ML-readiness flags
218
+ (`char_len`, `alpha_frac`, `is_short`, `boilerplate_flag`) and cross-batch
219
+ duplicate cluster ids (`exact_dup_id`, `near_dup_id`). Duplicate clustering
220
+ is self-contained (no datasketch dep): `exact_dup_id` over
221
+ whitespace/case/punctuation-normalised text, `near_dup_id` via a fixed-seed
222
+ MinHash+LSH (default 64 perms / 4 bands ≈ Jaccard 0.92). Annotation only —
223
+ chunk text is never mutated; runs as a single global pass since dedup is
224
+ corpus-wide. `boilerplate_patterns` are corpus-driven config, never
225
+ hardcoded. New `QualityConfig`; 5 unit tests.
226
+ - **`normalise` stage — `unicode_hygiene` transform.** Folds unicode
227
+ whitespace (NBSP, en/em spaces, ideographic space, U+2028/9 separators) to
228
+ ASCII space/newline and strips zero-width marks, BOM and stray control
229
+ chars; smart quotes and em/en dashes are preserved. New `unicode_hygiene`
230
+ toggle on `NormaliseConfig` (default on), composed ahead of the existing
231
+ transforms. 4 new unit tests.
232
+
233
+ ### Fixed
234
+ - **OpenCV 5 compatibility in skew detection.** `detect_skew_angle`
235
+ (`ingest/heuristics_cv2.py`) unpacked `HoughLinesP` segments as `line[0]`,
236
+ which assumes OpenCV 4's `(N, 1, 4)` layout; OpenCV 5 flattens to `(N, 4)`,
237
+ turning `line[0]` into a scalar and crashing every OCR-path extraction
238
+ (`TypeError: cannot unpack non-iterable numpy.int32`). The segments are now
239
+ reshaped to `(-1, 4)` before unpacking, which accepts both layouts. New
240
+ direct unit tests pin both shapes so the regression no longer needs the
241
+ full OCR fixture suite to surface.
242
+ - **mypy no longer pins `python_version = "3.11"`.** The pin forced the CI
243
+ 3.12 matrix leg to re-check under 3.11 grammar — redundant with the 3.11
244
+ leg, and broken once numpy ≥ 2.5 (3.12-only) began shipping PEP 695 `type`
245
+ statements in its stubs, which mypy rejects under a 3.11 target. Each leg
246
+ now checks at its own interpreter version.
247
+ - **Register manifest now covers `ingest-geo` and derives roles from footer
248
+ metadata only.** `cmd_ingest_geo` never called `write_register_manifest`
249
+ despite the documented `abn`/`gnaf`/`geo` coverage, and the module's
250
+ namespace whitelist said `geo` while `ingest/geospatial.py` writes
251
+ `geospatial.*` footer keys — so geo outputs could not be indexed at all.
252
+ The namespace is now taken from whichever `<ns>.source_file` footer key is
253
+ present (no per-register registry to keep in sync), geo ingest writes the
254
+ manifest like ABN/G-NAF, and the ABN ingest tags each output with an
255
+ `abn.role` footer key (`records`/`names`) so the manifest's role column
256
+ comes from metadata rather than the `_names` filename suffix — the exact
257
+ glob-style fragility the manifest exists to remove. ABN outputs written
258
+ before this change lack the role key and re-index as `records`; re-run
259
+ `ingest-abn` to restore the distinction. Also renames the module's
260
+ `RUN_MANIFEST_FILENAME` constant to `REGISTER_MANIFEST_FILENAME` — it had
261
+ borrowed the *run* manifest's constant name from `store/run_manifest.py`
262
+ while naming a different artefact.
263
+ - **`RemoteStore` no longer leaks s3fs-shaped options into non-S3 backends.**
264
+ `storage_options_from_env()` built AWS-style kwargs (`key`, `secret`,
265
+ `client_kwargs.endpoint_url`) and `from_uri` applied them to *any* remote
266
+ protocol, so a `gs://`/`az://` store with AWS env vars set (common in mixed
267
+ environments) got misconfigured. The helper now takes the target URI and
268
+ returns options only for `s3://`; other backends authenticate via their own
269
+ native mechanisms. Also: `womblex enqueue`'s batch-size fallback now reads
270
+ `ProcessingConfig()` instead of restating the default, and the worker
271
+ derives its shard-upload glob from the `BatchOutcome.shard_path` the batch
272
+ reported, so the `batch-NNNN` naming scheme lives only in `womblex.batch`.
273
+ - **K9-fig — full-page scans no longer dropped from chunking as `figure`.**
274
+ The dominant-region fallback in `_layout_blocks_and_tables` collapsed a
275
+ whole page's OCR onto one block tagged with the largest layout region's
276
+ kind; when that was DocLayNet `Picture` → `figure`, a full-page scanned
277
+ document became a single `figure` element, which is excluded from the
278
+ chunk narrative (`figure` ∉ `TEXT_KINDS`) — silently losing the document.
279
+ New shared helper `_ocr_region_block_type(text, layout_kind)` promotes a
280
+ non-text fallback kind to `paragraph` when the OCR yields ≥5 words; sparse
281
+ output (page-number stamps, bare logos) keeps `figure`. (The original E4
282
+ audit mis-attributed this to `_ocr_image_regions`; that path now routes
283
+ through the same helper.) On the ACT-ECI corpus: `figure` 1,200→154,
284
+ `paragraph` +1,046, and all 16 previously zero-chunk complaint documents
285
+ now produce chunks (docs-with-chunks 2,610→2,626). 4 new unit tests.
286
+
287
+ ### Added
288
+ - **I7 — entity-link sidecar: `womblex enrich` + `womblex link` per-stage CLIs.**
289
+ Two new per-stage stages mirroring `womblex chunk --shards`, each with an
290
+ independent `CheckpointManager` and per-batch sibling parquets.
291
+ `womblex enrich --shards <dir>` reassembles each doc's narrative
292
+ (`reassemble_narrative`), calls the Kanon-2 enricher one doc at a time
293
+ (per-doc failure isolation), and writes `*.enrichment_entities.parquet` +
294
+ `*.enrichment_meta.parquet` (reusing `store/enrichment_output.py` schemas,
295
+ keyed on `source_hash`). `womblex link --shards <dir> --config <yaml>`
296
+ resolves enrichment candidates (corporate persons + address locations) to a
297
+ reference register and writes `*.entity_links.parquet`. **Generic by design:**
298
+ the schema uses an `entity_type` discriminator (no domain columns), the
299
+ matcher (`link/matcher.py`) is generic record-linkage (alias → address-exact
300
+ → token-set name-fuzzy, stdlib `difflib`, no new dependency), and the corpus
301
+ declares register column-roles via the new `linking`/`reference` config — the
302
+ library knows nothing about specific registers. Reference loading
303
+ (`link/reference.py`) is bundle-aware by interface (CSV implemented; the
304
+ multi-file/geospatial seam is reserved, not built). Doc-grain attribution is
305
+ a derived read view over the persisted mention-grain rows, not a second file.
306
+ New `isaacus` is the optional extra (`uv sync --extra isaacus`). Live smoke
307
+ over the 17-doc Artemis set attributed 15/17 to the correct canonical service
308
+ (`SE-40002132`); the 2 misses are an enrichment-recall gap and an
309
+ OCR-typo+no-address doc, not matcher faults. New `tests/test_link.py` (23) +
310
+ `tests/test_enrich_stage.py`; full fast suite green.
311
+ - **Matcher** uses stdlib `difflib` only (no rapidfuzz dependency): alias →
312
+ address-exact → name-fuzzy, where name-fuzzy combines a token-set ratio
313
+ (suburb-suffix recall, cross-brand precision) with OCR-tolerant per-token
314
+ char similarity (folds "Earty"→"Early" while still rejecting a different
315
+ brand). With OCR tolerance the Artemis smoke reaches **16/17**.
316
+ - **`enrich`** isolates per-doc failures and, critically, does **not**
317
+ checkpoint a doc whose enrichment errored — a transient/connection failure
318
+ stays unprocessed so a resume retries it (regression-tested).
319
+ - **`womblex embed --shards` — chunk embeddings stage (Kanon-2 embedder).**
320
+ `analyse/embed.py` (thin `embeddings.create` wrapper: 128-text batching, 429
321
+ retry, order-preserving, `retrieval/document`/`query` task-aware) +
322
+ `analyse/embed_stage.py` (`embed_shards` over `*.chunks.parquet` →
323
+ `*.embeddings.parquet`, one vector per chunk, per-stage `CheckpointManager`,
324
+ batch-level failure isolation) + `cli/embed.py` + `EmbeddingConfig` +
325
+ `EMBEDDINGS_SCHEMA`/IO. The substrate for downstream search/clustering and a
326
+ doc→entity attribution backstop for no-extraction docs. `tests/test_embed_stage.py`.
327
+ - **I8–I10 — `womblex pii --shards` per-stage CLI: graph-driven detection +
328
+ `<PERSON_n>` masking.** Reads `*.chunks.parquet` + `*.enrichment_entities.parquet`
329
+ and writes `*.pii_spans.parquet` (audit; one row per span with `entity_id` +
330
+ `replacement`) plus a masked `*.clean_text.parquet` (publishable text layer,
331
+ drop-in for chunks). The Kanon-2 graph is the primary entity source
332
+ (`natural`→PERSON, `address`→ADDRESS); graph spans map onto narrative chunks
333
+ via `chunk.start_char`. Masking is **terminal** — applied after enrich + embed,
334
+ never rewriting the raw chunks that feed Isaacus. Tags are typed + numbered per
335
+ document off the graph entity (`<PERSON_1>`…). `pii/cleaner.py` refactored to a
336
+ span-returning `detect_spans()` + shared `_anonymize()`; the regex/cosine
337
+ backstop is now opt-in (`PIIConfig.use_regex_backstop`, default off — low
338
+ precision). New `store/pii_output.py`, `pii/pii_stage.py`, `cli/pii.py`,
339
+ `PIIConfig.write_clean_text`. `tests/test_pii_stage.py`.
340
+ - **I3 — `womblex redact --shards` per-stage CLI.** `womblex redact` is
341
+ now dual-mode, mirroring `womblex chunk`: `--shards <dir> --pdfs <dir>`
342
+ runs per-stage redaction detection over an existing extraction shard
343
+ directory and writes `*.redactions.parquet` siblings; `--config <yaml>`
344
+ runs the E2E extract+redact path unchanged. The `--shards`/`--config`
345
+ group is mutually exclusive and required. `--pdfs` is mandatory in
346
+ `--shards` mode because detection rasterises the source pages (unlike
347
+ chunking, which works purely from the element stream). The per-stage
348
+ path calls the existing `redact.batch.annotate_redactions_for_shards`
349
+ engine via a shared `_run_redact_shards` helper. 8 new CLI tests.
350
+
351
+ ### Changed
352
+ - **`operations.py` split into an `operations/` package.** The 902-line module
353
+ (over the 750-line cap) became one module per independent operation
354
+ (`models`/`extract`/`redact`/`chunk`/`pii`/`enrich`/`persist`), each ≤90
355
+ lines. The flat import surface (`from womblex.operations import run_extraction`,
356
+ …) is preserved by `operations/__init__` re-exports — no caller changes.
357
+ Behaviour-neutral; `test_integration.py` patch targets for `create_chunker`
358
+ moved to `womblex.operations.chunk`.
359
+ - **Resume-integrity self-heal generalised across stages.** `store/shard_audit.py`
360
+ gains `reconcile_stage_checkpoint_with_shards(mgr, dir, *, suffix)`; the chunk
361
+ reconcile now delegates to it, and `enrich`/`link`/`embed` wire it (+
362
+ `--no-verify-resume`) — so every `CheckpointManager`-backed stage drops +
363
+ re-does batches with corrupt sidecars on resume, identically.
364
+ - **I5 — SemChunk wrapper audit (P2).** Audited `process/chunker.py`
365
+ against semchunk 3.2.5: `create_chunker` exposes every `chunkerify`
366
+ creation parameter and `chunk_batch` passes every relevant
367
+ `Chunker.__call__` parameter through (`offsets=True` pinned because
368
+ Womblex needs char offsets for page mapping). No semchunk-native
369
+ surface is reimplemented or shadowed. **Removed the dead
370
+ `ChunkingConfig.batch` flag** — it mapped to no semchunk parameter,
371
+ was consumed by no code path, and its description referred to the
372
+ pre-I2 per-document-vs-batch behaviour that I2 deleted (chunk_batch
373
+ always batches the whole input list). **Widened `chunk_size` to
374
+ `int | None`** so semchunk's auto-derive path (`None` → size from the
375
+ tokeniser's `model_max_length`) passes through faithfully; the default
376
+ stays `480` (the Kanon-2 window), so behaviour is unchanged unless a
377
+ config explicitly sets `chunk_size: null`. Documented the adapter
378
+ boundary explicitly in the `chunker.py` module docstring, the
379
+ `ChunkingConfig` docstring, and `docs/extraction.md`; the three
380
+ default divergences from upstream (`tokenizer`, `chunk_size=480`,
381
+ `processes=1`) are each annotated with their corpus reason. Pure
382
+ thin-adapter cleanup — chunk output is byte-identical to I2 by
383
+ construction (the removed field was never read; the new `None` path is
384
+ opt-in). 98 chunker/config/pipeline/output tests pass; 79 integration
385
+ tests pass.
386
+ - **`annotate-redactions` is now a deprecated back-compat alias** for
387
+ `redact --shards <dir> --pdfs <dir>`. Its positional-argument surface
388
+ (`annotate-redactions <shards> <pdfs>`) is preserved verbatim and routes
389
+ through the same `_run_redact_shards` helper, so existing scripts keep
390
+ working with byte-identical output. New callers should prefer
391
+ `redact --shards`. The redact stage retains the engine's JSON
392
+ `--checkpoint` rather than the `CheckpointManager` used by `chunk`;
393
+ unifying the two is a deferred P1 follow-up.
394
+
395
+ ### Added
396
+ - **I2 — `womblex chunk --shards` per-stage CLI + `chunks.parquet`
397
+ sidecar.** New `CHUNKS_SCHEMA` (source_hash, chunk_index, text,
398
+ start_char/end_char, content_type, has_redaction, page_start /
399
+ page_end) in `store/output.py` with `write_chunks` / `read_chunks` /
400
+ `verify_chunks_persistence`. New `process/chunk_stage.py` walks a
401
+ shard directory, reassembles narrative + tables from the element
402
+ stream per source_hash, calls the single `chunk_batch` engine, and
403
+ writes a `*.chunks.parquet` sibling per batch. Per-stage
404
+ `CheckpointManager` keyed `<dataset>_chunk_checkpoint.json`;
405
+ chunks-side resume integrity in `shard_audit.scan_chunks_directory`
406
+ / `reconcile_chunk_checkpoint_with_shards` archives corrupt
407
+ `*.chunks.parquet` independently of the element-stream files. The
408
+ shared `chunk_batch` powers both per-stage `chunk_shards` and E2E
409
+ `operations.run_chunking`, so `--shards` and `--config` modes feed
410
+ semchunk identical inputs.
411
+
412
+ ### Changed
413
+ - **`process/chunker.py` collapsed against semchunk v3+ surface.**
414
+ Deleted per-doc wrappers `chunk_text`, `chunk_texts_batch`,
415
+ `chunk_document` (+ `_chunk_document_sequential` /
416
+ `_chunk_document_batch` dispatchers) — semchunk already batches
417
+ across a list of texts and parallelises over `processes` workers
418
+ when handed one. The new single entry point `chunk_batch(inputs,
419
+ chunker, ...)` flattens every doc's narrative into one semchunk
420
+ call (with `overlap`) and every doc's table markdowns into another
421
+ (no overlap), so `processes` and the progress bar parallelise
422
+ across the entire batch instead of being thrown away per-document.
423
+ `TextChunk` gained `page_start` / `page_end` (nullable); the
424
+ redaction-split repair pass propagates page spans across a merge.
425
+ New helpers `reassemble_narrative`, `collect_tables_from_elements`,
426
+ `build_chunk_input` formalise the "element stream → ChunkInput"
427
+ projection shared by both invocation paths.
428
+ - **`operations.run_chunking` rewired through `chunk_batch`.** Builds
429
+ one `ChunkInput` per completed result from
430
+ `dr.extraction.elements` (canonical), not `dr.extraction.full_text`
431
+ (which is derived from `pages` and reflects in-memory mutations).
432
+ Behaviour change: in-memory PII / redact-blackout mutations to
433
+ `pages[i].text` no longer flow to chunks under `womblex run`.
434
+ Aligns the E2E path with the per-stage one (both consume the
435
+ element stream); future PII / redact stages will reattach via
436
+ their sidecars per P1.
437
+
438
+ ### Added
439
+ - **Shard integrity scan on `--resume` (E1).** New
440
+ `womblex.store.shard_audit.reconcile_checkpoint_with_shards` runs at
441
+ the top of `cmd_run` when `--resume` is given. Walks every batch's
442
+ four sibling parquet files: confirms presence + non-empty + parquet-
443
+ readable, and that manifest `elements_count` / `table_cells_count` /
444
+ `form_fields_count` sums match the actual sidecar row counts. Any
445
+ batch failing a check has its `doc_id`s dropped from the checkpoint
446
+ and its files renamed with a `.corrupt` suffix so reader globs
447
+ (`*.elements.parquet` etc.) skip them; the dropped docs get re-
448
+ extracted into new batches past the high-water mark. Batches whose
449
+ manifest is itself unreadable can't be reconciled automatically (no
450
+ way to enumerate `doc_id`s) — they're logged loudly and the operator
451
+ is told to re-run without `--resume`. Defaults on; opt out with
452
+ `--no-verify-resume`. Closes the silent-failure class of post-write
453
+ filesystem corruption (drive glitch, partial sync, manual deletion)
454
+ that motivated the i1b batch-0087 0-byte incident.
455
+
456
+ - **`womblex verify-shards` CLI (E2).** Audits a run / shard directory
457
+ for corruption + cross-batch consistency; takes a shard dir or a run
458
+ root (auto-detects `documents/`). Reports per-batch integrity, total
459
+ elements / methods / kind counts, dupe and empty hashes. With
460
+ `--compare-to <other>` produces a side-by-side diff against another
461
+ run (useful for K-cluster-style "what changed between two
462
+ extractions" investigations — promotes the ad-hoc `i1b_audit.py`
463
+ pattern to first-class library + CLI). Optional `--input-dir <pdfs>`
464
+ surfaces source-vs-manifest count drift. Exits 2 when corruption is
465
+ detected so CI / cron pipelines can fail loudly. New module:
466
+ `womblex.store.shard_audit`. New tests in `tests/test_shard_audit.py`
467
+ (19).
468
+
469
+ ### Changed
470
+ - **Manifest schema gains `doc_id` column.** `MANIFEST_SCHEMA` now
471
+ carries the extraction's `doc_id` directly, removing the implicit
472
+ `Path(filename).stem == checkpoint.doc_id` coincidence that previously
473
+ bound the resume reconcile join. The reader (`read_manifest`) is
474
+ backward-compatible: manifests written before the bump derive
475
+ `doc_id` from `Path(filename).stem` on read so existing runs reconcile
476
+ without re-extraction. Parser version bump is intentionally deferred
477
+ — the schema is additive and reads gracefully.
478
+
479
+ ### Added
480
+ - **K7(b) — Document-layout YOLO model (DocLayNet).** New default layout
481
+ checkpoint `yolo11n_doc_layout.pt` (5.37 MB,
482
+ [Armaggheddon/yolo11-document-layout](https://huggingface.co/Armaggheddon/yolo11-document-layout),
483
+ MIT) replaces the COCO-trained `yolov8n.pt` as the primary layout
484
+ backend. `YOLOLayoutAnalyzer` detects the loaded model's taxonomy from
485
+ its class names: DocLayNet's 11 document classes (Caption, Footnote,
486
+ Formula, List-item, Page-footer, Page-header, Picture, Section-header,
487
+ Table, Text, Title) map directly into womblex `ElementKind` values
488
+ via the new `_YOLO_DOCLAYNET_LABEL_MAP`. COCO weights remain as a
489
+ best-effort fallback when the DocLayNet checkpoint isn't resolvable.
490
+ Inference imgsz follows a per-taxonomy default (DocLayNet: 832, COCO:
491
+ 640) — empirically equivalent on this corpus to the model card's 1280
492
+ recommendation while matching COCO speed; override to 1280 when
493
+ small-class (Caption / Footnote) recall matters. Closes the
494
+ 1,587-element `kind='figure'` mis-classification on scanned pages
495
+ tracked in docs/decisions.md; unlocks Caption / Footnote producers
496
+ (K6 closes as a side effect).
497
+
498
+ - **`footnote` ElementKind.** New text-bearing kind added to
499
+ `ElementKind`, `TEXT_KINDS`, and `_BLOCK_TYPE_TO_KIND`. Primary
500
+ producer is the DocLayNet `Footnote` class via the new label map.
501
+ Downstream stages (PII / redact / chunk) operate on text kinds and
502
+ pick up the new kind automatically through `TEXT_KINDS`. Future
503
+ iterations may refine signatory / footnote separation now that the
504
+ distinction is preserved.
505
+
506
+ - **K2′ — OCR form-pair bboxes.** New `_extract_form_pairs_from_regions`
507
+ in `ingest/forms.py` walks per-region OCR detections (PaddleOCR /
508
+ RapidOCR per-line bboxes) and produces `FormField` entries with real
509
+ positions. `_apply_ocr_page` prefers this path; the legacy
510
+ `_extract_form_pairs_from_lines` survives as a fallback for LLM-OCR
511
+ engines that resolve reading order natively and don't emit per-region
512
+ bboxes. Closes the K2′ silent-zero-bbox issue on 4,184 of 5,183 OCR
513
+ form elements (80.7%). Same plumbing unblocks inline-per-span
514
+ redaction markers on raster pages (P6 option (c); see docs/decisions.md).
515
+
516
+ ### Changed
517
+ - **`@pytest.mark.slow` tests now run by default.** Removed the
518
+ `-m 'not slow'` default from `[tool.pytest.ini_options].addopts`. The
519
+ 24 OCR-fixture tests in `tests/test_fixtures.py` were originally marked
520
+ slow because they invoked EasyOCR (30+ seconds each). The backend has
521
+ since moved to rapidocr-onnxruntime and the whole cohort completes in
522
+ ~7 seconds, so excluding them was costing coverage without saving
523
+ meaningful time. The `slow` marker is retained (description updated)
524
+ so users can still pass `-m 'not slow'` or `-m slow` for ad-hoc
525
+ filtering.
526
+
527
+ ### Added
528
+ - **`run_id` + retention plumbing (I1 of publishable-corpus track).**
529
+ Pipeline runs now write outputs to `<output_root>/<run_id>/documents/`
530
+ rather than `<output_root>/documents/`. Run id resolution order:
531
+ `--run-id` CLI flag → `dataset.run_id` in config → auto-generated
532
+ `run-YYYYMMDDTHHMMSSZ` timestamp. `--resume` without a run id picks
533
+ the most-recent existing run dir. Checkpoints follow under
534
+ `<checkpoint_dir>/<run_id>/`.
535
+
536
+ New `processing.retention` config block: `policy: rolling | keep_all`
537
+ (default `rolling`) and `keep: int` (default `2`). On fresh runs (not
538
+ `--resume`), old run dirs beyond the retention window are purged in
539
+ lockstep with their checkpoint dirs. The current run is always
540
+ preserved regardless of position.
541
+
542
+ Foundation for stage-aware sidecar persistence (I2+); no extraction
543
+ output content has changed.
544
+
545
+ New module: `womblex.store.retention` (`generate_run_id`, `list_runs`,
546
+ `most_recent_run`, `apply_retention`). New tests in
547
+ `tests/test_retention.py` (20) and `tests/test_config.py` (8).
548
+
549
+ Retention only considers subdirectories whose name starts with `run-`
550
+ — legacy / hand-named output dirs (e.g. `output/documents/` from a
551
+ pre-run_id layout) are preserved unconditionally and must be removed
552
+ manually if no longer wanted. To bring a hand-named run under the
553
+ policy, name it with a `run-` prefix.
554
+
555
+ ### Changed — BREAKING
556
+ - **Extraction output reshaped to element-stream + typed sidecars.**
557
+ `ExtractionResult` now carries `elements: list[Element]` as the
558
+ canonical structural stream. Per-batch parquet output is split across
559
+ four sibling files: `batch-NNNN.elements.parquet` plus
560
+ `.table_cells.parquet`, `.form_fields.parquet`, `._manifest.parquet`.
561
+ Sidecars are joinable via `(source_hash, parent_elem_order)`. The
562
+ previous one-parquet-per-batch shape (`EXTRACTION_SCHEMA` with nested
563
+ struct lists for tables / forms / images / text_blocks) is removed.
564
+ See `docs/extraction.md` for the canonical reference.
565
+ - **Narrative ↔ table interleaving preserved.** DOCX bodies are walked
566
+ in OOXML order so paragraphs and tables emit in their true position;
567
+ PDF per-page outputs are sorted by `(y, x)` so within-page reading
568
+ order survives.
569
+ - **Spreadsheets: one ExtractionResult per workbook.** Cells are the
570
+ element grain (`kind='sheet_cell'`); sheets emit a leading
571
+ `sheet_meta` element. The previous one-result-per-row shape is
572
+ removed.
573
+ - **Verbatim text at the schema boundary.** `_normalise_text` no longer
574
+ runs in the extraction hot path. Page text and element text are
575
+ emitted verbatim from the producing extractor. Downstream stages may
576
+ still mutate `pages[i].text`; the on-disk parquet is unaffected.
577
+ - **Legacy view properties remain.** `ExtractionResult.text_blocks` /
578
+ `.tables` / `.forms` / `.images` are now read-only derived views over
579
+ `elements` so downstream PII / redact / chunk stages continue working
580
+ without change.
581
+ - **Sidecar integrity check.** `verify_shard_persistence` now confirms
582
+ every `(source_hash, parent_elem_order)` in `table_cells` /
583
+ `form_fields` resolves to an element with the matching kind. Drift
584
+ raises `ShardVerificationError`.
585
+
586
+ ### Added
587
+ - **Per-page extraction plan + orchestrator.** PDFs now route via per-page
588
+ profiling (`ingest/page_profile.py`) and a doc-wide orchestrator
589
+ (`ingest/orchestrator.py`) that dispatches operations page-by-page,
590
+ rather than picking one document-level strategy. Correctly handles
591
+ heterogeneous FOI bundles (e.g. one image-only page in an otherwise-
592
+ native doc) that the previous sample-based detector missed.
593
+ - **Spreadsheet-print extractor** (`ingest/spreadsheet_print.py`) for
594
+ native PDFs printed from a spreadsheet (CSV/Excel) — common shape for
595
+ government FOI manifests, schedules, and registers. Extracts row-by-row
596
+ data plus a metadata block (the label-value fields above the first
597
+ data row). Handles 90°-rotated landscape pages, multi-line headers,
598
+ centred header text within wider cells, and frozen headers across
599
+ pages. Triggered by a cheap qualifier (filename hints + table signal)
600
+ before structural vetting. Config-driven via
601
+ `extraction.native.spreadsheet_print` (`metadata_location`,
602
+ `filename_hints`).
603
+ - **Form-pair text extractor** (`ingest/forms.py`) — picks up "Field:
604
+ value" form pairs in native PDFs (`_extract_form_pairs_from_text`)
605
+ and OCR'd text (`_extract_form_pairs_from_lines`). Forms-column
606
+ populated for ~94 % of docs in the ACT FOI corpus (was 0 %).
607
+ - **`block_type` classifier rewrite.** Native blocks now typed by
608
+ position + typography (footer, header, signature, heading, paragraph)
609
+ instead of length-only fallback. `caption` reserved for image-adjacent
610
+ blocks downstream.
611
+ - **Per-image OCR on native pages** with embedded image regions (e.g.
612
+ letterhead text). Generalised from hybrid-only via the existing
613
+ `_ocr_image_regions` helper; recovers text-in-images that previously
614
+ fell through extraction.
615
+ - **Multi-strategy table detection** — PyMuPDF `find_tables(strategy=
616
+ "lines")` falls back to `strategy="text"` when the document uses
617
+ whitespace-aligned columnar layout instead of ruled cells.
618
+ - **`TableData.context` and `ExtractionResult.document_metadata`** —
619
+ per-table and document-level metadata blocks for spreadsheet-print
620
+ layouts.
621
+ - `womblex profile` CLI command for per-column schema inference on
622
+ CSV / XLSX / XLS / Parquet / NDJSON files.
623
+
624
+ ### Changed
625
+ - **PDF dispatch flips from doc-level to page-level.** PDF strategy
626
+ classes (`Native*Extractor`, `Scanned*Extractor`, `HybridExtractor`,
627
+ `StructuredExtractor`) are deleted; `get_extractor()` now only
628
+ dispatches non-PDF types (DOCX, spreadsheet, text, image). The
629
+ orchestrator is the canonical PDF path.
630
+ - **Source redaction and PII tokens unified on angle brackets.**
631
+ `redact/stage.py` blackout mode emits `<REDACTED>` (was `[REDACTED]`);
632
+ `pii/cleaner.py` emits `<ENTITY_TYPE>` (e.g. `<PERSON>`, `<ADDRESS>`,
633
+ `<EMAIL>`). Aligns with the labelling-packet ground-truth fixtures
634
+ (`_transcript-with-redacted-tags*.txt`). The BPE/SentencePiece
635
+ tokenisation argument that previously motivated square brackets didn't
636
+ hold up — neither bracket style is single-piece in standard pretrained
637
+ tokenisers without explicit special-token registration. Source
638
+ redactions remain page-prefix (not inline per span) — inline-per-span
639
+ requires bbox-to-text mapping not currently routed through to the
640
+ redact stage; tracked as a follow-up.
641
+
642
+ ### Internal
643
+ - Moved page-image morphology helpers from `detect.py` into
644
+ `ingest/morphology.py` to keep `detect.py` under the 750-line cap.
645
+ - `_normalise_text` regex extended to catch fullwidth Unicode `Page`
646
+ variants in OCR'd footers (`5lpage`, `3lPage`).
647
+
648
+ ### Packaging
649
+ - MANIFEST.in no longer packages all of `src/womblex/_models/` — only the
650
+ en_AU Hunspell dictionary ships, matching the declared package-data. The
651
+ previous rule pulled every local model artefact (~110 MB compressed) into
652
+ both the sdist and, via setuptools' `include-package-data` default, the
653
+ wheel — over PyPI's 100 MB per-file limit. Large models remain resolved
654
+ via `WOMBLEX_MODELS_DIR`.
655
+ - PyPI metadata completed: `readme`, project URLs; setuptools floor raised
656
+ to >=77 for PEP 639 SPDX licence string support.
657
+ - The kanon-2 tokenizer (~5 MB) is bundled in the wheel alongside the en_AU
658
+ dictionary, so pip-installed token packing and chunking resolve it locally
659
+ with no Hugging Face round-trip, as already documented.
660
+
661
+ ## [0.1.0] - 2026-04-28
662
+
663
+ ### Added
664
+ - Initial public release.
665
+ - PDF extraction with document-type routing (native, scanned, hybrid, forms, tables).
666
+ - DOCX and spreadsheet (CSV/XLSX/XLS) ingestion.
667
+ - Standalone G-NAF and geospatial (SHP) ingest paths.
668
+ - Post-extraction redaction stage with configurable insertion points.
669
+ - Optional PII cleaning (PERSON detection via regex + cosine-similarity validation).
670
+ - semchunk-based chunking with table-aware splitting.
671
+ - Optional Isaacus enrichment via the `[isaacus]` extra.
672
+ - Local model resolution via `WOMBLEX_MODELS_DIR` for offline / edge deployment.
673
+
674
+ ## Historical engineering notes (migrated from STATUS.md 2026-05-28)
675
+
676
+ > These sections record the point-in-time engineering detail behind the
677
+ > structured `[Unreleased]` / `[0.1.0]` entries above: the extraction-quality
678
+ > session bundle (native-page tables, block-aware paragraphs, footer
679
+ > pipe-as-I, the reverted OCR-table relaxation), the Phase 1–6 roadmap
680
+ > history, the CLI restructure, the detector evolution (vector-first +
681
+ > raster-path layout filter), and the redaction/PII marker-convention
682
+ > unification. The durable decisions, dead-ends, limitations and deferred
683
+ > backlog now live in [docs/decisions.md](docs/decisions.md); corpus-specific
684
+ > run history is a corpus concern (see the corpus's own status notes).
685
+ > (These notes were originally migrated from a now-removed `STATUS.md`.)
686
+
687
+ ## What changed
688
+
689
+ The session bundled four production-hardening fixes addressing
690
+ distinct quality gaps surfaced by per-page GT comparison against the
691
+ labels packet at `stories/ACT_EarlyChildhoodIncidents/womblex-extract/labels/`.
692
+ A fifth proposed fix was tried, validated against labels, and
693
+ reverted on evidence of irreducible regression elsewhere.
694
+
695
+ ### 1. Native-page table column-major emission (`extract.py` + `orchestrator.py`)
696
+
697
+ **Problem.** Native PDFs with ruled tables (Compliance Notice rules-of-
698
+ the-Law pages, e.g. 00281 page 1) had their cells correctly detected
699
+ into `tables[]` by `_extract_tables_from_page` but the prose `text`
700
+ field still read the same cells row-major via
701
+ `page.get_text("text")`, producing garble like
702
+ `"Provision of the Description Steps to be taken Law Section The
703
+ approved provider of The Provider to is to submit evidence..."` —
704
+ the data was present in `tables[]` but the prose field that
705
+ downstream chunking consumes was scrambled.
706
+
707
+ **Fix.**
708
+ - New `_find_native_tables(page)` returning
709
+ `list[tuple[TableData, fitz.Rect, list[list]]]` — exposes per-table
710
+ bbox + raw cell grid alongside the existing `TableData`.
711
+ - New `_emit_table_column_major(cells)` — emits each column as its
712
+ own paragraph (cells joined by `\n` within a column, columns
713
+ separated by `\n\n`). Mirrors the OCR-side `_table_aware_text` shape.
714
+ - `_extract_tables_from_page` is now a thin wrapper around
715
+ `_find_native_tables` — preserves the legacy `list[TableData]`
716
+ signature for callers that don't need bboxes.
717
+ - `_apply_native_page` calls `_find_native_tables`, partitions table
718
+ rects out of the prose call via `extract_page_text(page,
719
+ exclude_rects=…)`, and appends the column-major emissions
720
+ in y-order.
721
+
722
+ **Gating.** Only `confidence ≥ 0.8` (lines-strategy hits, ruled cells)
723
+ drive prose-region exclusion. Text-strategy hits (whitespace-aligned
724
+ columns, conf ≈ 0.6) stay in `tables` but **do not** partition the
725
+ prose, because text-strategy false-positives on ordinary multi-column
726
+ prose (e.g. 2-column layouts with regular x-spacing) — caught by
727
+ `test_native_extractor_handles_two_column_page` in the test suite
728
+ before the gating was added.
729
+
730
+ The spreadsheet-print path (Phase 4) handles whitespace-aligned
731
+ manifests separately via `extract_spreadsheet_print`, so the gating
732
+ doesn't lose that case.
733
+
734
+ ### 2. Block-aware paragraph emission (`grid_projection.py`)
735
+
736
+ **Problem.** For single-column native pages, `extract_page_text` fell
737
+ back to `page.get_text("text", flags=TEXT_DEHYPHENATE)`, which joins
738
+ adjacent blocks with a single `\n`. Paragraph breaks between numbered
739
+ list items, bullets, headings, and footers were lost — `01132 page 1`
740
+ extracted as one continuous paragraph with section breaks invisible to
741
+ downstream chunking.
742
+
743
+ **Fix.**
744
+ - `extract_page_text` accepts `exclude_rects: Sequence[fitz.Rect] |
745
+ None`. Words whose midpoint falls inside any rect are filtered
746
+ before column projection.
747
+ - Single-column path now routes through `_render_blocks_with_breaks`,
748
+ which iterates `page.get_text("blocks", flags=TEXT_DEHYPHENATE)`
749
+ and joins blocks with `\n\n`. Blocks whose centre falls inside an
750
+ exclude rect are dropped (used in concert with fix 1 to suppress
751
+ table-region prose).
752
+ - New `_word_in_any_rect(word, rects)` helper for the word-level
753
+ filter.
754
+
755
+ The multi-column path (≥2 columns from `project_to_columns`)
756
+ continues to use `render_spatial_text(columns)` unchanged.
757
+
758
+ ### 3. Body-context pipe-as-I (`extract.py` `_normalise_text`)
759
+
760
+ **Problem.** ACT Gov letter footers (`GPO Box 158 Canberra ACT 2601 |
761
+ phone: 132281 | www.act.gov.au`) OCR the `|` separator as a capital
762
+ `I` when it sits between a space and a lowercase keyword:
763
+ `2601 I phone:`. The existing `_FOOTER_PIPE_RE` only catches the
764
+ page-marker shape `<digit>lPage`.
765
+
766
+ **Fix.** `_BODY_PIPE_RE = re.compile(r" I (?=(?:phone|email|fax|www|http)\b)",
767
+ re.IGNORECASE)` — restricted to a fixed keyword set to avoid false
768
+ positives on legitimate sentence-initial `I` + verb. Applied
769
+ alongside the existing footer rule in `_normalise_text` (RES-004b).
770
+
771
+ ### 4. `format_labels.TITLE_PATTERNS` regex anchor bug *(stories-side
772
+ script, not Womblex code)*
773
+
774
+ **Problem.** In
775
+ `stories/.../womblex-extract/format_labels.py`, the title patterns
776
+ embed `^` at the start (`r"^(SHOW CAUSE NOTICE|COMPLIANCE NOTICE|…)"`).
777
+ That works for `TITLE_RE.search`, but the same patterns were also
778
+ substituted in-string via `re.sub(rf"\s*({pat})", …)`, where `^`
779
+ restricted the match to position 0 — silently no-op'd for the same
780
+ title appearing mid-string. So `Dear COMPLIANCE NOTICE Section 177…`
781
+ never got the `\n\n` break inserted before `COMPLIANCE NOTICE`.
782
+
783
+ **Fix.** Removed `^` from `TITLE_PATTERNS`; dropped `re.IGNORECASE`
784
+ from the title substitution loop so body-text mentions like *"this
785
+ compliance notice"* don't false-trigger paragraph breaks.
786
+
787
+ Drives the **hybrid mean CER 0.208 → 0.145** result in the labels
788
+ retest — affects every label page where a known title appears
789
+ mid-stream after a redacted name.
790
+
791
+ ### 5. *(reverted)* OCR-side `_table_aware_text` relaxation
792
+
793
+ **Tried.** Relax start condition from `min_start_rows=2` consecutive
794
+ ≥3-item rows to a single 3+-item row, and allow 1-item continuation
795
+ rows (which OCR produces for multi-line cell wraps).
796
+
797
+ **Why reverted.** Helped table-shaped pages (R-01313F 0.080 → 0.018,
798
+ 00281 0.27 → 0.22) but caused matching regression on form-shaped
799
+ pages (R-04060 0.020 → 0.157) — CRM-style screenshots where many
800
+ 1-item label/value rows look like a multi-line cell wrap, get
801
+ absorbed into a "table", emitted column-major as one blob.
802
+
803
+ Tried four progressively stricter discriminators (2-row start,
804
+ consecutive-singleton cap, single-column-only singleton absorption,
805
+ column-spread minimums). None recovered baseline for both classes
806
+ of page. The signature of a real multi-line cell wrap and a form
807
+ field stream is structurally similar at the per-region level; we
808
+ couldn't separate them without page-image context the OCR pipeline
809
+ doesn't carry.
810
+
811
+ **Decision.** Revert to baseline. The native-path fix above already
812
+ handles the ruled-table case on the production native code path;
813
+ OCR-side ruled-table detection on rendered images stays at original
814
+ conservative behaviour. Trade-off documented; do not retry without
815
+ new discriminating signal.
816
+
817
+ ## Verification
818
+
819
+ ### Unit tests
820
+ `tests/test_extract.py` (40), `tests/test_grid_projection.py` (17),
821
+ `tests/test_spreadsheet_print.py` (14); the full-suite snapshot lives
822
+ in the Snapshot table above (numbers shift as new tests land).
823
+ Two new behaviours not yet covered by tests:
824
+ - `_emit_table_column_major` (covered indirectly via the native
825
+ integration test)
826
+ - `_render_blocks_with_breaks` paragraph-separator output
827
+ (covered indirectly via fixture accuracy)
828
+
829
+ ### Labels retest
830
+ `stories/.../womblex-extract/cer_results.md` (18 reviewed pages):
831
+
832
+ | strategy | n | baseline mean CER | bundle mean CER |
833
+ |---|---:|---:|---:|
834
+ | hybrid | 2 | 0.208 | **0.145** |
835
+ | native_with_structured | 3 → 6 | 0.020 | 0.051 (+3 new pages) |
836
+ | scanned_machinewritten | 7 | 0.044 | 0.044 |
837
+ | scanned_mixed | 3 | 0.104 | 0.104 |
838
+
839
+ Newly-reviewed pages (no prior baseline):
840
+ - `02424A p4`: 0.000 — true-blank page, predicted empty matches GT empty
841
+ - `01132 p0`: 0.014 — body pipe-as-I + TITLE_PATTERNS fix visible
842
+ - `00281 p0`: 0.233 — OCR-quality residue (`Govemment` typo not in
843
+ normaliser; mid-sentence drop in the "satisfied that" clause).
844
+ Documented as OCR-character-level limit, not pipeline gap.
845
+
846
+ ### Native-path source-PDF validation
847
+ Real `00281.pdf` page 1 (native_with_structured): ruled rules-of-the-
848
+ Law table extracts as 1 detected table; prose section emits
849
+ block-by-block with `\n\n` between `Time for Compliance / You are
850
+ required… / Failure to comply / It is an offence…`; table content
851
+ follows column-major: column 1 (`Section / 167(1) / Section / 174(2)`),
852
+ column 2 (the approved-provider description), column 3 (steps).
853
+ Behaviour matches design intent.
854
+
855
+ ## Roadmap
856
+
857
+ ### Phases 1–4 (✅ complete, prior session)
858
+ Per-page profile + plan-driven orchestrator (Phase 2), form-pair
859
+ extraction & block-type classifier rewrite (Phase 1), per-image OCR
860
+ & PII token swap & fullwidth footer (Phase 3), spreadsheet-print
861
+ extractor (Phase 4). All metrics maintained in the bundle's
862
+ acceptance test against the same random-500 sample.
863
+
864
+ ### Phase 5 — Production hardening (✅ complete, prior session)
865
+ Four bundled fixes above. Validated against the labels packet and
866
+ the full 2,626-doc corpus. See `stories/STATUS.md` for the
867
+ production-corpus run output and quality audit.
868
+
869
+ ### CLI surface
870
+
871
+ `womblex score --labels <dir> --shards <dir> [--group-by FIELD] [--report PATH]`
872
+ — promoted from a corpus-local script to first-class CLI in
873
+ 2026-05-17. Scores a per-page labels packet (`<stem>.gt.md` +
874
+ `<stem>.meta.json`) against per-page text reassembled from the
875
+ element-stream parquet. See `src/womblex/score.py` for the module API
876
+ (`load_labels`, `build_manifest_index`, `reassemble_page_text`,
877
+ `score_labels`, `format_report_markdown`).
878
+
879
+ ### Library — `redact/batch.py` (2026-05-19, batch redaction operations)
880
+
881
+ Promoted from a corpus-local validation script. Two entry points:
882
+
883
+ - `annotate_redactions_for_shards(shard_dir, pdf_dir, config, output_dir,
884
+ checkpoint_path)` — batch-detect redactions across extracted parquet
885
+ shards; write a sparse `*.redactions.parquet` sidecar per batch with
886
+ `(source_hash, elem_order, has_redaction)` rows for elements on
887
+ affected pages. Resumable via the optional checkpoint JSON.
888
+ - `validate_redactions_against_labels(labels_dir, pdf_dir, config)` —
889
+ run detection over PDFs referenced in a labels packet; return per-doc
890
+ `ValidationSummary` objects. Used for detector tuning / sanity checks.
891
+
892
+ CLI wrappers landed 2026-05-20 — see CLI restructure below.
893
+
894
+ ### CLI — `cli.py` → `cli/` subpackage (2026-05-20)
895
+
896
+ Single 728-line `cli.py` (at the 750-line cap) split into a per-topic
897
+ subpackage. Each topic module exposes a ``COMMANDS: list[Command]`` and
898
+ ``cli/__init__.py`` aggregates them, wires up argparse subparsers, and
899
+ dispatches by name.
900
+
901
+ ```
902
+ src/womblex/cli/
903
+ ├── __init__.py main() + ALL_COMMANDS aggregation + dispatch
904
+ ├── _shared.py Command NamedTuple, setup_logging, discover_files, format_eta
905
+ ├── pipeline.py run, extract, chunk
906
+ ├── redact.py redact, annotate-redactions, validate-redactions
907
+ ├── ingest.py ingest-gnaf, ingest-geo
908
+ ├── score.py score
909
+ └── profile.py profile
910
+ ```
911
+
912
+ Two new CLI subcommands landed in `cli/redact.py`:
913
+
914
+ - `womblex annotate-redactions <shards> <pdfs> [--output DIR] [--checkpoint PATH]`
915
+ — invokes `redact.batch.annotate_redactions_for_shards`. Resumable.
916
+ - `womblex validate-redactions --labels DIR --pdfs DIR [--report PATH]`
917
+ — invokes `redact.batch.validate_redactions_against_labels`. JSON or
918
+ markdown output.
919
+
920
+ Entry point `womblex = "womblex.cli:main"` unchanged (cli/__init__.py
921
+ exports `main`). All existing subcommand surfaces preserved verbatim
922
+ (args, help text, behaviour). Heavy double-line spacing from the old
923
+ file normalised during the move.
924
+
925
+ Largest file post-split: `cli/pipeline.py` at 283 lines (38% of cap).
926
+
927
+ ### Detector — vector-first detection (2026-05-19)
928
+
929
+ `redact/stage.py:detect_redactions` was extended to try
930
+ `page.get_drawings()` first for filled near-black rectangles; falls back
931
+ to the existing CV2 contour detector on rasterised pages when the
932
+ vector path finds nothing. Both paths return `RedactionInfo` with
933
+ bboxes in pixel coordinates at the configured DPI.
934
+
935
+ Filters (each surfaced by a measured false-positive class during Phase
936
+ 2 validation):
937
+
938
+ - Near-black fill (max channel ≤ 0.1 RGB; CMYK K ≥ 0.9 + others ≤ 0.1) —
939
+ baseline filter.
940
+ - `min_width ≥ 3pt` — excludes narrow vertical separator lines that
941
+ appear in manifest-style tables (FOI master regression on 37 pages).
942
+ - `min_height ≥ 8pt` — excludes glyph-rendering small filled rects on
943
+ PDFs that draw text as filled-path glyphs rather than vector text
944
+ (01125-class regression: 14,184 false positives → 144 actual).
945
+
946
+ Closes Outstanding §4(a) in `stories/STATUS.md`. Validated against the
947
+ labels packet: §1 residual recall improved 6→14, 7→13, 3→68 without
948
+ regressing the FOI master manifest (0 regions preserved) or any
949
+ plain-scanned doc. The 02737-class scanned_mixed false-positive cohort
950
+ falls back to the raster path and is unchanged (documented limitation,
951
+ see `stories/STATUS.md` §11).
952
+
953
+ ### Phase 6 — `kind='table'` over-firing (2026-05-17, ✅ resolved)
954
+
955
+ Audit of the pre-refactor corpus run found 62% of `structured`-strategy
956
+ docs shipping pseudo-tables built from form layouts and shredded prose;
957
+ the same primitive over-fired more broadly across the 2,791 PDF table
958
+ records corpus-wide. Two fixes landed, in source code rather than
959
+ config because Womblex didn't expose the right knobs:
960
+
961
+ - **§1 — `_find_native_tables` block-count gate** (`ingest/extract.py`).
962
+ Reject any PyMuPDF table candidate where the count of natural
963
+ `get_text("dict")` blocks inside the table bbox is less than the row
964
+ count the table claims. Real tables decompose into ≥1 block per row;
965
+ prose-as-table over-claims rows by carving sub-block whitespace into
966
+ pseudo-rows. ~15 LOC + the `_count_blocks_in_bbox` helper.
967
+ - **§2 — `_has_manifest_table` + `PageProfile.has_manifest_signal`**
968
+ (`ingest/detect.py`, `ingest/page_profile.py`). Stricter signal than
969
+ `has_table_signal`: only fires when a page contains a table with ≥300
970
+ non-empty cells — the discriminator between real manifests (FOI
971
+ master 1,713 non-empty cells per page; Schedule-2ai–2av 503 per page)
972
+ and prose-as-table over-fires (170–280 per page). The
973
+ `qualify_for_spreadsheet_print` qualifier now gates on this stricter
974
+ signal, so regulatory letters with embedded rules-of-law tables stop
975
+ routing through the manifest extractor.
976
+
977
+ Validated by re-running extraction across the full 2,626-doc corpus
978
+ (2026-05-17) and auditing `kind='table'` elements + cells:
979
+
980
+ | stratum | pre-fix | post-fix | residual |
981
+ |---|---:|---:|---|
982
+ | conf=0.60 (`native_text` text-strategy) | 2,362 | 3 | 3 known prose-as-table on heavily-redacted pages (added to labels packet for follow-up) |
983
+ | conf=0.70 (`spreadsheet_print`) | 83 | 3 | All real manifests (FOI master, Schedule-2ai–2av, Schedule-2b); regulatory-letter misroutes eliminated |
984
+ | conf=0.80 (`native_text` lines-strategy) | 346 | 166 | ~90% clean rules-of-law tables; ~10% borderline (single-row / mostly-empty) |
985
+ | **total** | **2,791** | **172** | **~3-5% residual** |
986
+
987
+ The 3 residual conf=0.60 fabrications survive because PyMuPDF's
988
+ text-strategy `find_tables` clusters the text fragments left over
989
+ after large redaction blocks fragment the surrounding prose into
990
+ whitespace-aligned columns. The bars themselves do not register as
991
+ cells (measured cell-vs-fill overlap ≤ 1% across all three pages);
992
+ they cause the misread indirectly via the gap pattern they create.
993
+ The §1 gate's `n_blocks ≥ n_rows` premise is satisfied because the
994
+ count of surviving paragraph dict-blocks happens to match the count
995
+ of synthesised rows. Content impact is contained: text-bearing
996
+ elements on these pages capture the prose verbatim (the
997
+ `kind='table'` element is additive noise, not corruption). A
998
+ region-level black-fill signal could close the strong-signal page
999
+ (01349, 28% bbox coverage) but is on a threshold tightrope for
1000
+ 01093/01094 (~3.5% coverage) where false-positives on real native
1001
+ tables become a real risk. Accepted as documented limitation; see
1002
+ `stories/STATUS.md` Outstanding §2. Material impact ~0.11% of source
1003
+ docs.
1004
+
1005
+ ### Redaction & PII marker conventions (✅ unified 2026-05-21, bracket-only)
1006
+
1007
+ Two distinct concerns, two distinct markers:
1008
+
1009
+ | concern | source | marker | inline per span | metadata home |
1010
+ |---|---|---|---|---|
1011
+ | Source redaction | rendered black bar in PDF (FOI / publisher) | `<REDACTED>` | not yet — see below | `RedactionReport` per-span (bbox, page, method, confidence) |
1012
+ | PII redaction | detected in extracted text (regex + cosine + enrichment graph) | `<PERSON>`, `<EMAIL>`, `<ADDRESS>`, … (typed) | yes | enrichment graph + chunk `has_redaction` flag |
1013
+
1014
+ Bracket-style unification on angle brackets landed across:
1015
+
1016
+ - [pii/cleaner.py:331,406](src/womblex/pii/cleaner.py#L331) — emits `<ENTITY_TYPE>` (PII spans). Module docstring rationale rewritten; the prior BPE/SentencePiece tokenisation argument for square brackets didn't survive scrutiny (neither bracket style is single-piece in standard pretrained tokenisers without explicit special-token registration).
1017
+ - [redact/stage.py:187](src/womblex/redact/stage.py#L187) — `blackout` mode emits `<REDACTED>` (still page-prefix, not inline per span — see below).
1018
+ - [process/chunker.py:227](src/womblex/process/chunker.py#L227) — `_repair_redaction_splits` marker constant flipped to `<REDACTED>`; cross-file coupling comment preserved.
1019
+ - [operations.py:298](src/womblex/operations.py#L298), [config.py:86](src/womblex/config.py#L86) — docstrings.
1020
+ - [docs/architecture.md](docs/architecture.md), [docs/dataflow.md](docs/dataflow.md) — manual references aligned. [docs/accuracy/REDACTION_HANDLING.md](docs/accuracy/REDACTION_HANDLING.md) is generated by `test_fixture_accuracy.py` and will regenerate on next accuracy-benchmark run.
1021
+ - Tests across `test_pii.py`, `test_pii_enrichment.py`, `test_chunker.py`, `test_redaction.py`, `test_womblex_collection_accuracy.py`, `accuracy_reports.py` — all assertions migrated. The 3 previously-failing ADDRESS angle-bracket tests now pass.
1022
+
1023
+ **Inline-per-span for source redactions is deferred** — flipping the bracket style alone is bracket-only behaviour-preserving. Going inline-per-span requires a bbox-to-text-position mapping that doesn't exist for raster-path redactions (pixel-only bboxes with no character index). Native-path (PDF vector) detection has PDF coords and could be mapped; OCR/raster path needs word-bbox routing wired through to the redact stage. Tracked as a follow-up.
1024
+
1025
+ No on-disk data migration was needed — production runs to date are flag-mode, no `blackout` text mutation has been applied, and PII cleaning hasn't been applied to the corpus.
1026
+
1027
+ ### Detector — raster-path layout filter (2026-05-22)
1028
+
1029
+ `RedactionDetector.detect()` accepts a new `exclude_rects` parameter; candidates whose bbox centre falls inside any rect are dropped. `redact/stage.py:detect_redactions()` runs YOLO layout analysis on raster-fallback pages and passes regions of `_LAYOUT_EXCLUSION_CLASSES` (`tv`, `laptop`, `monitor`, `cell phone`, `keyboard`, `mouse`, `book`, `dining table` — the COCO classes that heuristically land on form-field backgrounds and chart regions) as exclusion zones to the contour detector. Best-effort: try/except, falls back to raw raster pass on any error (missing ultralytics, model weights absent).
1030
+
1031
+ Gated by `RedactionConfig.use_layout_filter: bool = True` (default on). Threaded through `operations.py:run_redaction`, `redact/batch.py:annotate_redactions_for_shards`, `redact/batch.py:validate_redactions_against_labels`. CLI / `configs/example.yaml` exposes the flag.
1032
+
1033
+ **Cost:** Vector-path detection (native PDFs) is unchanged — YOLO never runs there. Raster-fallback path triggers YOLO inference per page, which materially slowed the accuracy benchmark (3 min → 22 min for `test_womblex_collection_accuracy.py`). For production batch runs on scanned_mixed cohorts the trade is correct (precision over speed).
1034
+
1035
+ **Cohort measurement (2026-05-22).** Ran `detect_redactions` on the 11 scanned_mixed docs from the corpus with the filter off vs on (config: `RedactionConfig(max_area_ratio=0.05)`, the corpus tune):
1036
+
1037
+ | metric | off | on | Δ |
1038
+ |---|---:|---:|---:|
1039
+ | total regions across 11 docs | 159 | 151 | **−8 (−5.0%)** |
1040
+ | docs with any region | 10 | 10 | 0 |
1041
+ | 02737-213A (the signature case) | 10 | 10 | **0** |
1042
+ | runtime | 9.5s | 12.6s | +3.1s (1.3×) |
1043
+
1044
+ **Interpretation.** The COCO YOLO model produces useful exclusion zones on 6 of 11 docs but at very small magnitudes (−1 to −3 per doc). It does **not** touch the worst case — 02737's 10 regions across 2 pages, the cohort's most egregious false positives, are entirely missed by the COCO classes the filter listens for. The hypothesis that `tv` / `laptop` / `monitor` etc. would heuristically land on dark form-field backgrounds was too weak: YOLO either doesn't detect those classes on rendered CRM-form pages, or detects them but not on the regions where contour detection misfires.
1045
+
1046
+ The filter is net-positive (more precise without regressing any doc) but the magnitude is too small to close §11's `scanned_mixed` false-positive gap on its own. A document-layout-trained checkpoint (DocLayNet / PubLayNet, with `Figure` / `Table` / `Form` classes) would be a better fit (the DocLayNet swap later landed — see CHANGELOG `[Unreleased]`).
1047
+
1048
+ **Default decision.** Keeping `use_layout_filter=True` is defensible (no doc regressed; some precision gain) but the value is modest and the test-suite runtime cost is real. Best path forward is the YOLO swap — see "Open follow-ups" K7(b) / track #A.
1049
+
1050
+ ### Non-`table` element kind audit (2026-05-22)
1051
+
1052
+ Corpus-wide audit of element kinds beyond the resolved `kind='table'` work. Counts (90,843 elements across 2,626 docs) and per-strategy distribution live in `stories/STATUS.md` "Non-`table` element kind audit". Top findings, in summary form (full data + per-fix code-site references in the stories STATUS):
1053
+
1054
+ - **`kind='signature'` is 100% semantically wrong.** `_SIGNATURE_RE` matches `Yours sincerely` / `faithfully` / `truly` — i.e. the closing phrase, not the signatory block. All 442 signature elements in the corpus are closing-phrase matches. Actual signatory blocks (name + title + redaction bar) are filed as `paragraph`.
1055
+ - **`kind='figure'` is 65% mis-classified on scanned pages** (1,044 of 1,600). Root cause: `_YOLO_COCO_LABEL_MAP` defaults unknown COCO classes to `figure`, and on rendered scanned-page images YOLO finds plenty of unknown classes. These "figures" contain OCR text — they're text-bearing elements filed under a non-text kind.
1056
+ - **Three element kinds declared but never produced.** `list_item`, `caption`, `page_break` — schema enum + mapping table both present, no producer. Lists are extremely common in regulatory documents; the capability is silently absent.
1057
+ - **`header` block_type falls through to `paragraph`.** `_classify_native_block` returns `"header"` for `y_norm < 0.08`, but `"header"` isn't in `ElementKind` — it gets demoted into `meta['block_type']`, downstream consumers reading `kind` miss it.
1058
+ - **`kind='form'` over-fires on regulatory letters.** `Penalty: $10 000, in the case of an individual` (regulation citation) and `OFFICIAL: Sensitive - Legislative Secrecy` (document banner) get matched as form pairs. ~250-500 spurious in hybrid + structured cohorts.
1059
+ - **Bboxes for native-text elements serialise as `(0, 0, 0×0)`** — silent data-loss bug. Visible in every native sample but separate from kind classification. Tracked as K2.
1060
+
1061
+ Eight concrete fixes K1-K8 written up in "Open follow-ups" above. K1 / K3 / K4 / K5 / K7(a) / K8 land naturally as one low-effort change set. K2 and K7(b) are separate larger tracks.
1062
+
1063
+ ### Deferred / cut
1064
+
1065
+ - ~~**Refresh accuracy-report generator + regenerate accuracy docs**~~ (resolved 2026-05-22). `tests/accuracy_reports.py` generator strings refreshed: `generate_redaction_report` now describes the vector-first detector + YOLO exclusion zones + `run_redaction` operation; `generate_extraction_report` now describes the per-page orchestrator + element-stream kinds + four sibling parquet shards; the strategy-matrix column reflects per-page dispatch. The four generated accuracy docs (`EXTRACTION.md`, `REDACTION_HANDLING.md`, `PII_CLEANING.md`) were regenerated cleanly. `docs/accuracy/CHUNKING.md` framings updated by hand for the post-refactor spreadsheet shape; a generator for this doc is still to-be-written (numbers still date from 2026-03-22).
1066
+ - **OCR-side ruled-table column-major emission** — irreducible
1067
+ trade-off documented under fix 5 above. Re-evaluate only with a
1068
+ new discriminating signal (e.g. layout-from-image-vision pass)
1069
+ that separates real table cells from form-field streams.
1070
+ - **Parquet schema for `TableData.context` + `document_metadata`** —
1071
+ Largely resolved by the element-stream refactor (2026-05-16):
1072
+ per-element ``meta`` map carries arbitrary key-value overflow on
1073
+ the elements shard; ``document_metadata`` rides on the manifest.
1074
+ ``TableData.context`` is preserved on a kind='table' element via
1075
+ ``meta`` keys (``context_*``). Original deferral note kept for
1076
+ history.
1077
+ - **Letterhead-typo normalisations for `Govemment` / standalone
1078
+ `AcT`** — the existing `_LETTERHEAD_FIXES` covered the parenthesised
1079
+ `(AcT)` and double-m `Govermment` shapes. Under the post-refactor
1080
+ verbatim-text policy, `_normalise_text` no longer runs in the
1081
+ extraction hot path, so adding entries to `_LETTERHEAD_FIXES` would
1082
+ have no effect on the on-disk parquet. If letterhead-typo correction
1083
+ is required, it now belongs to a downstream cleaning stage that
1084
+ rewrites `pages[i].text`, not the extractor.
1085
+ - **`02737`-style cross-cell handwritten forms** — paddle's
1086
+ row-major reading is architecturally mismatched with forms that
1087
+ humans read by cell, AND the handwriting itself crosses cell
1088
+ boundaries. Structural OCR-engine limit; not addressable in
1089
+ Womblex without a layout-aware OCR backend.
1090
+
1091
+ [Unreleased]: https://github.com/DeepCivic/womblex/compare/v0.2.0...HEAD
1092
+ [0.2.0]: https://github.com/DeepCivic/womblex/compare/v0.1.0...v0.2.0
1093
+ [0.1.0]: https://github.com/DeepCivic/womblex/releases/tag/v0.1.0