|
| 1 | +# Docudactyl — Investigator Toolkit |
| 2 | + |
| 3 | +<!-- |
| 4 | +SPDX-License-Identifier: PMPL-1.0-or-later |
| 5 | +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 6 | +--> |
| 7 | + |
| 8 | +This document describes the **investigator-focused extraction modules** added |
| 9 | +to Docudactyl for use by citizen journalists, independent researchers, and |
| 10 | +investigative reporters working with large document releases such as the |
| 11 | +Epstein filings, Panama/Paradise/Pandora Papers, FinCEN Files, etc. |
| 12 | + |
| 13 | +These modules are **standalone Zig translation units** with stable C-ABI |
| 14 | +entry points. They can be called directly from Chapel, Rust, Julia, OCaml, |
| 15 | +Python `ctypes`, or any language that speaks the C FFI — without going |
| 16 | +through the full HPC pipeline. |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## Why these modules exist |
| 21 | + |
| 22 | +The base HPC pipeline answers "what is in this document?". Investigative |
| 23 | +journalism needs different questions: |
| 24 | + |
| 25 | +1. **Who appears with whom, how often?** → [`entity_graph`](#entity-graph) |
| 26 | +2. **What was hidden under black bars?** → [`redaction_recovery`](#redaction-recovery) |
| 27 | +3. **Where did the jet actually go?** → [`flight_log`](#flight-log) |
| 28 | +4. **Where did the witness stop answering?** → [`evasion_detect`](#evasion-detect) |
| 29 | +5. **What does this document contain, at a glance?** → [`investigator_summary`](#investigator-summary) |
| 30 | + |
| 31 | +Each module is **pattern-based, no ML dependency**, fast, and deterministic. |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## Entity Graph |
| 36 | + |
| 37 | +**File:** `ffi/zig/src/entity_graph.zig` |
| 38 | + |
| 39 | +Builds a cross-document co-occurrence graph of capitalised personal names. |
| 40 | +Exports to **GraphML** (Gephi, yEd, Cytoscape) and **CSV** (Excel, |
| 41 | +LibreOffice, Maltego, Neo4j). |
| 42 | + |
| 43 | +### C ABI |
| 44 | + |
| 45 | +```c |
| 46 | +typedef struct EntityGraph EntityGraph; |
| 47 | + |
| 48 | +EntityGraph* ddac_entity_graph_new(void); |
| 49 | +void ddac_entity_graph_free(EntityGraph*); |
| 50 | + |
| 51 | +int ddac_entity_graph_add_document(EntityGraph*, const char* text, size_t len); |
| 52 | +int ddac_entity_graph_export_graphml(EntityGraph*, const char* path); |
| 53 | +int ddac_entity_graph_export_csv(EntityGraph*, const char* path); |
| 54 | + |
| 55 | +uint32_t ddac_entity_graph_node_count(EntityGraph*); |
| 56 | +uint32_t ddac_entity_graph_edge_count(EntityGraph*); |
| 57 | +``` |
| 58 | +
|
| 59 | +### Usage sketch |
| 60 | +
|
| 61 | +```chapel |
| 62 | +// Chapel pseudocode |
| 63 | +var g = ddac_entity_graph_new(); |
| 64 | +for doc in manifest { |
| 65 | + var text = readExtractedText(doc); |
| 66 | + ddac_entity_graph_add_document(g, text.c_str(), text.len); |
| 67 | +} |
| 68 | +ddac_entity_graph_export_graphml(g, "/out/entities.graphml"); |
| 69 | +ddac_entity_graph_export_csv(g, "/out/entities.csv"); |
| 70 | +ddac_entity_graph_free(g); |
| 71 | +``` |
| 72 | + |
| 73 | +Load `entities.graphml` in Gephi → run ForceAtlas2 → see the cluster |
| 74 | +structure. Load `entities.csv` in any spreadsheet to sort by weight. |
| 75 | + |
| 76 | +### Notes |
| 77 | +- Extracts 2+ consecutive capitalised words, optionally preceded by a |
| 78 | + title (Mr./Mrs./Dr./Prince/Sir/…). |
| 79 | +- Filters a conservative stopword list (weekdays, months, common |
| 80 | + sentence-initial words). |
| 81 | +- Edge weight accumulates across documents — high weight indicates |
| 82 | + recurring co-occurrence worth examining. |
| 83 | + |
| 84 | +--- |
| 85 | + |
| 86 | +## Redaction Recovery |
| 87 | + |
| 88 | +**File:** `ffi/zig/src/redaction_recovery.zig` |
| 89 | + |
| 90 | +Extends the base redaction-detection stage with **per-page density maps** |
| 91 | +and **overlay-only text recovery**. When a PDF carries `/Redact` |
| 92 | +annotations (black boxes) but the underlying content stream is intact, |
| 93 | +this module extracts the text that the overlay was meant to hide. |
| 94 | + |
| 95 | +### When it works |
| 96 | + |
| 97 | +✅ Overlay redactions where the text stream was NOT scrubbed (common |
| 98 | +FOIA failure mode — many Epstein-era productions exhibit this). |
| 99 | +❌ Redactions that rasterise the page or strip the content stream. |
| 100 | +❌ Redactions applied at scan time (pixel-level black bars). |
| 101 | + |
| 102 | +### C ABI |
| 103 | + |
| 104 | +```c |
| 105 | +typedef struct { |
| 106 | + int status; |
| 107 | + uint32_t total_pages; |
| 108 | + uint32_t total_redactions; |
| 109 | + uint32_t pages_with_redactions; |
| 110 | + uint32_t recoverable_pages; |
| 111 | + uint64_t recovered_bytes; |
| 112 | + PageStats pages[4096]; |
| 113 | + char summary[512]; |
| 114 | +} RedactionRecoveryResult; |
| 115 | + |
| 116 | +int ddac_redaction_recovery_analyze(const char* pdf_path, RedactionRecoveryResult*); |
| 117 | +int ddac_redaction_recovery_dump_text(const char* pdf_path, const char* out_path); |
| 118 | +``` |
| 119 | +
|
| 120 | +### Legal & ethical note |
| 121 | +
|
| 122 | +This module extracts text that is **already present** in the document's |
| 123 | +content stream — the same text that `cmd-A, cmd-C` in Preview would |
| 124 | +reveal. It does not break encryption, does not OCR under pixel-level |
| 125 | +redactions, and does not decode protected content. Use responsibly and |
| 126 | +check your local jurisdiction's rules on reporting improperly redacted |
| 127 | +material. |
| 128 | +
|
| 129 | +--- |
| 130 | +
|
| 131 | +## Flight Log |
| 132 | +
|
| 133 | +**File:** `ffi/zig/src/flight_log.zig` |
| 134 | +
|
| 135 | +Extracts travel-document entities from text: |
| 136 | +
|
| 137 | +| Entity | Examples | |
| 138 | +|--------|----------| |
| 139 | +| Tail numbers | `N908JE`, `N212JE`, `G-EJES`, `D-IIKA` | |
| 140 | +| IATA codes | `TEB`, `PBI`, `JFK`, `STT`, `LHR`, `CDG`, `DXB` | |
| 141 | +| ICAO codes | `KTEB`, `KPBI`, `KJFK`, `EGLL`, `LFPB` | |
| 142 | +| Phones | `(212) 555-1234`, `+1 212 555 9999`, `+44 20 7946 0958` | |
| 143 | +| Addresses | Line-leading number + road-word heuristic | |
| 144 | +| Manifest markers | `PAX:`, `PASSENGERS:`, `MANIFEST:`, `GUESTS:` | |
| 145 | +
|
| 146 | +### C ABI |
| 147 | +
|
| 148 | +```c |
| 149 | +typedef struct { /* ... */ } FlightLogResult; |
| 150 | +int ddac_flight_log_process(const char* text, size_t len, FlightLogResult*); |
| 151 | +``` |
| 152 | + |
| 153 | +### Notes |
| 154 | +- IATA/ICAO codes use a **whitelist** of airports of interest |
| 155 | + (Teterboro, Palm Beach, St. Thomas, Le Bourget, Heathrow, Dubai, |
| 156 | + etc.) to avoid false positives on three-letter acronyms like `CEO` |
| 157 | + or `FBI`. Extend the whitelist in the module source as needed. |
| 158 | +- Tail-number pattern is liberal enough to tolerate OCR noise but |
| 159 | + tight enough to reject ordinary words. |
| 160 | + |
| 161 | +--- |
| 162 | + |
| 163 | +## Evasion Detect |
| 164 | + |
| 165 | +**File:** `ffi/zig/src/evasion_detect.zig` |
| 166 | + |
| 167 | +Detects and categorises evasive / non-answer patterns in deposition and |
| 168 | +interview transcripts: |
| 169 | + |
| 170 | +| Category | Example phrase | |
| 171 | +|----------|---------------| |
| 172 | +| `no_recall` | "I don't recall", "I have no recollection" | |
| 173 | +| `no_memory` | "I don't remember", "I can't remember" | |
| 174 | +| `not_sure` | "I'm not sure", "I couldn't say" | |
| 175 | +| `no_knowledge` | "Not to my knowledge", "I'm not aware" | |
| 176 | +| `would_check` | "I'd have to check", "I would need to check" | |
| 177 | +| `asked_answered` | "Asked and answered" (lawyer interjection) | |
| 178 | +| `fifth_amendment` | "Fifth Amendment", "on the advice of counsel" | |
| 179 | +| `decline_answer` | "I decline to answer", "refuse to answer" | |
| 180 | + |
| 181 | +Reports category counts, total events, and an **evasion rate** (events |
| 182 | +per 1000 tokens, fixed-point ×1000). A rate above ~20 (i.e. `×1000 > |
| 183 | +20000`) typically indicates a heavily evasive witness segment. |
| 184 | + |
| 185 | +### C ABI |
| 186 | + |
| 187 | +```c |
| 188 | +typedef struct { |
| 189 | + int status; |
| 190 | + uint32_t category_counts[8]; |
| 191 | + uint32_t total_events; |
| 192 | + uint32_t total_tokens; |
| 193 | + uint32_t events_per_1k_fixed; // rate × 1000 |
| 194 | + char summary[512]; |
| 195 | +} EvasionResult; |
| 196 | + |
| 197 | +int ddac_evasion_detect(const char* text, size_t len, EvasionResult*); |
| 198 | +``` |
| 199 | +
|
| 200 | +--- |
| 201 | +
|
| 202 | +## Investigator Summary |
| 203 | +
|
| 204 | +**File:** `ffi/zig/src/investigator_summary.zig` |
| 205 | +
|
| 206 | +Takes a populated `InvestigatorSummary` struct and emits an |
| 207 | +investigator-friendly **JSON summary** per document. Designed to be |
| 208 | +readable in a text editor and ingestible by spreadsheets, dataset |
| 209 | +browsers, or static-site generators. |
| 210 | +
|
| 211 | +The JSON is flat and forgiving: any field may be zero/empty without |
| 212 | +breaking consumers. A `flags` array gives quick visual triage: |
| 213 | +
|
| 214 | +```json |
| 215 | +{ |
| 216 | + "source_path": "/data/release_2024/doc_0042.pdf", |
| 217 | + "sha256": "...", |
| 218 | + "page_count": 184, |
| 219 | + "redactions": {"count": 12, "pages_affected": 4, "recoverable_pages": 2}, |
| 220 | + "financial": {"amounts": 3, "accounts": 1}, |
| 221 | + "legal": {"case_citations": 5, "dockets": 2, "statutes": 1}, |
| 222 | + "speakers": {"count": 2, "is_deposition": true}, |
| 223 | + "evasion": {"total": 17, "per_1k_tokens": 12.5}, |
| 224 | + "entities": { |
| 225 | + "persons": ["Jeffrey Epstein", "Ghislaine Maxwell"], |
| 226 | + "tail_numbers": ["N908JE"], |
| 227 | + "airports": ["TEB", "PBI", "KTEB"], |
| 228 | + "phones": ["+1 212 555 1234"], |
| 229 | + "addresses": ["9 East 71st Street"] |
| 230 | + }, |
| 231 | + "flags": ["has_redactions", "has_recoverable_text", "deposition", "high_evasion"] |
| 232 | +} |
| 233 | +``` |
| 234 | + |
| 235 | +### C ABI |
| 236 | + |
| 237 | +```c |
| 238 | +int ddac_investigator_summary_write(const char* out_path, const InvestigatorSummary*); |
| 239 | +int ddac_investigator_summary_set_list_item(StringList*, uint32_t idx, |
| 240 | + const char* text, size_t len); |
| 241 | +``` |
| 242 | +
|
| 243 | +--- |
| 244 | +
|
| 245 | +## Recommended Investigator Workflow |
| 246 | +
|
| 247 | +1. **Run base pipeline** (`DocudactylHPC`) to extract text + SHA + PREMIS. |
| 248 | +2. **Per-document pass** — for each extracted text file, call: |
| 249 | + - `ddac_flight_log_process` → flight / travel entities |
| 250 | + - `ddac_evasion_detect` → deposition evasion stats |
| 251 | + - `ddac_redaction_recovery_analyze` + `_dump_text` (PDFs only) |
| 252 | +3. **Corpus-wide pass** — accumulate entities into a single graph: |
| 253 | + - `ddac_entity_graph_new` |
| 254 | + - `ddac_entity_graph_add_document` per document |
| 255 | + - `ddac_entity_graph_export_graphml` + `_export_csv` |
| 256 | +4. **Summary pass** — populate `InvestigatorSummary` from the results |
| 257 | + above and emit per-document JSON with |
| 258 | + `ddac_investigator_summary_write`. |
| 259 | +5. **Review** — open the GraphML in Gephi, the CSV in a spreadsheet, and |
| 260 | + the per-document JSONs in a text editor or a static-site browser. |
| 261 | +
|
| 262 | +--- |
| 263 | +
|
| 264 | +## Building & Testing |
| 265 | +
|
| 266 | +From `ffi/zig/`: |
| 267 | +
|
| 268 | +```bash |
| 269 | +zig build # build shared + static libraries |
| 270 | +zig build test # run unit tests (all new modules included) |
| 271 | +``` |
| 272 | + |
| 273 | +All new modules have accompanying Zig unit tests. No new C dependencies |
| 274 | +beyond Poppler + GLib (which Docudactyl already links). |
| 275 | + |
| 276 | +--- |
| 277 | + |
| 278 | +## License |
| 279 | + |
| 280 | +All new modules are released under **PMPL-1.0-or-later** (with MPL-2.0 |
| 281 | +fallback), matching the rest of Docudactyl. |
0 commit comments