From 67392c9d1a0eef8a609586deaf81e922b859b985 Mon Sep 17 00:00:00 2001 From: Jithin Mathews Date: Mon, 9 Nov 2026 20:22:34 +0530 Subject: [PATCH 1/2] RRB-01: extraction toolkit (template, validator, converter, tests) --- docs/findings/rrb-01/tools/README.md | 59 +++ docs/findings/rrb-01/tools/rrb01_extract.py | 403 ++++++++++++++++++ .../rrb-01/tools/rrb01_extraction_entry.xlsx | Bin 0 -> 92907 bytes .../rrb-01/tools/test_rrb01_extract.py | 195 +++++++++ 4 files changed, 657 insertions(+) create mode 100644 docs/findings/rrb-01/tools/README.md create mode 100644 docs/findings/rrb-01/tools/rrb01_extract.py create mode 100644 docs/findings/rrb-01/tools/rrb01_extraction_entry.xlsx create mode 100644 docs/findings/rrb-01/tools/test_rrb01_extract.py diff --git a/docs/findings/rrb-01/tools/README.md b/docs/findings/rrb-01/tools/README.md new file mode 100644 index 0000000..fc43a0d --- /dev/null +++ b/docs/findings/rrb-01/tools/README.md @@ -0,0 +1,59 @@ +# RRB-01 extraction toolkit + +Format tooling for the founder extraction (and later the AI extraction) under the +locked protocol `rrb-01-protocol-v0.3.md` (tag `rrb-01-protocol-lock`). It enforces +the schema's format only. It makes no chemistry judgments and never decides whether +a passage qualifies. + +## Files + +| File | Purpose | +| --- | --- | +| `rrb01_extract.py` | `template`, `validate` and `convert` commands | +| `test_rrb01_extract.py` | Tests, synthetic data only | +| `rrb01_extraction_entry.xlsx` | Blank entry workbook (sheet `records`; sheet `legend` explains fields) | + +Requires Python 3.9+, `openpyxl`; tests need `pytest`. + +## Use + +``` +python rrb01_extract.py validate founder_extraction.xlsx +python rrb01_extract.py convert founder_extraction.xlsx founder_extraction.jsonl +python -m pytest test_rrb01_extract.py +``` + +`convert` writes nothing unless every row passes, never overwrites an existing file, +and writes `.jsonl.report.json` with the SHA-256 of both the workbook and the JSONL +plus record counts. + +## What the converter guarantees + +- Text is copied exactly as typed: no trimming, case change or formula normalization. + Leading or trailing spaces produce a warning and are kept. +- Every row is exported, including `exclude` and `ambiguous`. +- Decisions, parents and reasons are never inferred or changed. +- `parent_normalized` and `target_normalized` are always `null`; normalization is a + separate step after the extraction is preserved. +- Deterministic output: fixed key order, records sorted by source, page, record number; + UTF-8; LF line endings. + +## Implementation notes (format decisions within the locked schema) + +These interpret the locked schema's wording; none changes what qualifies as a pair. + +1. `composition_type` ("for parent and target separately") is entered as two columns and + exported as `{"parent": ..., "target": ...}`. The parent value is `null` when no parent + is named. +2. `direction_basis`, `relationship_type` and `characterization` are required for + `include` records and optional otherwise. +3. An empty parent is accepted only with `exclude` / `no_named_parent`, as the record + trigger requires. A parent entered with `no_named_parent` is a warning, not an error. +4. `confounders` is an open list of snake_case tokens separated by `;`. +5. Cells must be text. A number, date or formula cell is an error, so the spreadsheet + cannot silently convert values. + +## Relationship between the two files + +The workbook is the entry interface; the JSONL is the scientific record. Both are +preserved and committed unchanged, and the report file links them by SHA-256. diff --git a/docs/findings/rrb-01/tools/rrb01_extract.py b/docs/findings/rrb-01/tools/rrb01_extract.py new file mode 100644 index 0000000..299a60b --- /dev/null +++ b/docs/findings/rrb-01/tools/rrb01_extract.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""RRB-01 extraction toolkit (format only; makes no chemistry judgments). + +Commands + template write a blank entry workbook + validate [--extractor founder] check every row, write nothing + convert [--extractor founder] + validate, then write JSONL + report + +Guarantees (RRB-01 protocol lock, extraction schema): + * Text is copied exactly as entered: no trimming, case changes or + formula normalization. Whitespace problems are reported, never fixed. + * Every row is exported, including excludes and ambiguous records. + * Decisions, parents and reasons are never inferred or altered. + * parent_normalized / target_normalized are always null here; a separate + normalization step fills them after the extraction is preserved. + * Output is deterministic (fixed key order, record order, UTF-8, LF) and + an existing output file is never overwritten. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + +SCHEMA_VERSION = "rrb01-extract-1" +SHEET = "records" + +# Spreadsheet columns, in order. composition_type is entered as two columns +# and exported as {"parent": ..., "target": ...}. +COLUMNS = [ + "record_id", "source", "location", "evidence", "cited_primary_study", + "parent_as_published", "target_as_published", + "parent_composition_type", "target_composition_type", + "direction_basis", "relationship_type", "characterization", + "confounders", "decision", "exclusion_reason", "ambiguity_note", +] + +SOURCES = {"HWA17", "NOE21"} +EXTRACTORS = {"founder", "ai"} +COMPOSITION_TYPES = { + "fixed_integer", "fixed_fractional", "fixed_doped", "unresolved_variable", + "variable_with_stated_value", "family_no_composition", + "spatially_heterogeneous", +} +RELATIONSHIP_TYPES = {"cross_chemistry_analog", "within_family_substitution"} +CHARACTERIZATIONS = { + "formula_or_structural_analog", "proposed_replacement", + "experimentally_evaluated_substitute", +} +DECISIONS = {"include", "exclude", "ambiguous"} +EXCLUSION_REASONS = { + "no_named_parent", "not_directed", "synthesis_route_only", + "morphology_or_surface_only", "spatially_heterogeneous", + "unresolved_variable", "family_no_composition", +} +RECORD_ID = re.compile(r"^(HWA17|NOE21)-([1-9][0-9]*)-([1-9][0-9]*)$") +CONFOUNDER_TOKEN = re.compile(r"^[a-z][a-z0-9_]*$") +MAX_EVIDENCE_WORDS = 40 + +# JSONL key order (locked schema order). +OUTPUT_KEYS = [ + "schema_version", "extractor", "record_id", "source", "location", + "evidence", "cited_primary_study", "parent_as_published", + "target_as_published", "composition_type", "direction_basis", + "relationship_type", "characterization", "confounders", "decision", + "exclusion_reason", "ambiguity_note", "parent_normalized", + "target_normalized", +] + + +class Problem: + def __init__(self, row, field, message, error=True): + self.row, self.field, self.message, self.error = row, field, message, error + + def __str__(self): + kind = "ERROR" if self.error else "WARNING" + return f"{kind} row {self.row} [{self.field}]: {self.message}" + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest().upper() + + +# ---------------------------------------------------------------- template +def write_template(out: Path) -> None: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + from openpyxl.worksheet.datavalidation import DataValidation + + if out.exists(): + sys.exit(f"Refusing to overwrite existing file: {out}") + wb = Workbook() + ws = wb.active + ws.title = SHEET + font = Font(name="Arial", size=10) + head = Font(name="Arial", size=10, bold=True) + fill = PatternFill("solid", start_color="FFF2CC") + ws.append(COLUMNS) + for c in ws[1]: + c.font = head + c.fill = PatternFill("solid", start_color="D9E1F2") + widths = {"evidence": 60, "location": 28, "direction_basis": 40, + "ambiguity_note": 40, "cited_primary_study": 24} + for i, name in enumerate(COLUMNS, start=1): + letter = ws.cell(row=1, column=i).column_letter + ws.column_dimensions[letter].width = widths.get(name, 22) + # Text format for every entry cell so Excel never converts values + # (e.g. "1/3" to a date, "12" to a number). + for r in range(2, 2001): + cell = ws.cell(row=r, column=i) + cell.number_format = "@" + cell.font = font + cell.fill = fill + cell.alignment = Alignment(wrap_text=name in widths, vertical="top") + ws.freeze_panes = "B2" + + def dropdown(col_name, values): + col = ws.cell(row=1, column=COLUMNS.index(col_name) + 1).column_letter + dv = DataValidation(type="list", formula1='"' + ",".join(sorted(values)) + '"', + allow_blank=True, showErrorMessage=True) + ws.add_data_validation(dv) + dv.add(f"{col}2:{col}2000") + + dropdown("source", SOURCES) + dropdown("parent_composition_type", COMPOSITION_TYPES) + dropdown("target_composition_type", COMPOSITION_TYPES) + dropdown("relationship_type", RELATIONSHIP_TYPES) + dropdown("characterization", CHARACTERIZATIONS) + dropdown("decision", DECISIONS) + dropdown("exclusion_reason", EXCLUSION_REASONS) + + leg = wb.create_sheet("legend") + rows = [ + ("RRB-01 founder extraction entry workbook", ""), + ("", ""), + ("Where to type", "Sheet 'records', yellow cells only, one row per record. " + "Every cell is formatted as text; keep it that way."), + ("Converter", "python rrb01_extract.py convert "), + ("Rules", "The locked protocol (rrb-01-protocol-v0.3.md) defines what creates a " + "record and every field. This workbook only enforces format."), + ("record_id", "--, n = record number on that page in reading order. " + "Example: NOE21-4-2"), + ("parent_as_published", "Leave empty only when the source names no parent; such a " + "record must be exclude / no_named_parent."), + ("composition types", "parent_composition_type is empty when the parent is empty."), + ("include records", "need direction_basis, relationship_type and characterization."), + ("exclude records", "need exclusion_reason; ambiguity_note stays empty."), + ("ambiguous records", "need ambiguity_note; exclusion_reason stays empty."), + ("confounders", "snake_case tokens separated by ';' e.g. morphology_change;coating"), + ("cited_primary_study", "Reference number and DOI as printed, or the word none."), + ("evidence", f"Quote exactly as printed, at most {MAX_EVIDENCE_WORDS} words."), + ("", ""), + ("Format example (synthetic, not from either source)", ""), + ("record_id", "NOE21-99-1"), + ("location", "p.99, section 9.9, paragraph 2"), + ("evidence", "AbX2 was replaced by AbY2 to improve stability."), + ("cited_primary_study", "[999] 10.0000/example"), + ("parent / target", "AbX2 / AbY2 (types: fixed_integer / fixed_integer)"), + ("direction_basis", "was replaced by"), + ("relationship / characterization", "within_family_substitution / proposed_replacement"), + ("decision", "include"), + ] + for a, b in rows: + leg.append([a, b]) + for row in leg.iter_rows(): + for c in row: + c.font = font + leg["A1"].font = Font(name="Arial", size=12, bold=True) + leg["A16"].font = head + leg.column_dimensions["A"].width = 34 + leg.column_dimensions["B"].width = 100 + wb.save(out) + print(f"Template written: {out}") + + +# ---------------------------------------------------------------- reading +def read_rows(path: Path): + """Yield (excel_row_number, {column: raw value}) for non-empty rows.""" + from openpyxl import load_workbook + + wb = load_workbook(path, data_only=False, read_only=True) + if SHEET not in wb.sheetnames: + sys.exit(f"Workbook has no sheet named '{SHEET}'") + ws = wb[SHEET] + it = ws.iter_rows(values_only=True) + header = list(next(it, []) or []) + header = [h for h in header if h is not None] + if header != COLUMNS: + sys.exit("Header row does not match the locked column list.\n" + f"Expected: {COLUMNS}\nFound: {header}") + for n, values in enumerate(it, start=2): + values = list(values[:len(COLUMNS)]) + [None] * (len(COLUMNS) - len(values)) + if all(v is None or v == "" for v in values): + continue + yield n, dict(zip(COLUMNS, values)) + + +# ---------------------------------------------------------------- validation +def validate_rows(rows, extractor: str): + problems, records, seen = [], [], {} + + for n, raw in rows: + p = [] + + def err(field, msg): + p.append(Problem(n, field, msg)) + + def warn(field, msg): + p.append(Problem(n, field, msg, error=False)) + + val = {} + for k, v in raw.items(): + if v is None or v == "": + val[k] = None + elif isinstance(v, str): + val[k] = v + if v != v.strip(): + warn(k, "leading or trailing whitespace (kept as entered)") + if v.startswith("="): + err(k, "value starts with '=' (spreadsheet formula); enter text") + else: + err(k, f"cell is {type(v).__name__}, not text; format the cell as " + "text and retype it") + val[k] = None + + def required(field): + if val[field] is None: + err(field, "required") + + def enum(field, allowed): + if val[field] is not None and val[field] not in allowed: + err(field, f"'{val[field]}' is not one of {sorted(allowed)}") + + for f in ("record_id", "source", "location", "evidence", + "cited_primary_study", "target_as_published", + "target_composition_type", "decision"): + required(f) + enum("source", SOURCES) + enum("parent_composition_type", COMPOSITION_TYPES) + enum("target_composition_type", COMPOSITION_TYPES) + enum("relationship_type", RELATIONSHIP_TYPES) + enum("characterization", CHARACTERIZATIONS) + enum("decision", DECISIONS) + enum("exclusion_reason", EXCLUSION_REASONS) + + rid = val["record_id"] + if rid is not None: + m = RECORD_ID.match(rid) + if not m: + err("record_id", f"'{rid}' does not match --") + elif val["source"] is not None and m.group(1) != val["source"]: + err("record_id", f"prefix {m.group(1)} does not match source {val['source']}") + if rid in seen: + err("record_id", f"duplicate of row {seen[rid]}") + else: + seen[rid] = n + + if val["evidence"] is not None: + words = len(val["evidence"].split()) + if words > MAX_EVIDENCE_WORDS: + err("evidence", f"{words} words; limit is {MAX_EVIDENCE_WORDS}") + + parent = val["parent_as_published"] + if parent is None and val["parent_composition_type"] is not None: + err("parent_composition_type", "must be empty when the parent is empty") + if parent is not None and val["parent_composition_type"] is None: + err("parent_composition_type", "required when a parent is entered") + if parent is None and not (val["decision"] == "exclude" + and val["exclusion_reason"] == "no_named_parent"): + err("parent_as_published", "empty parent requires decision exclude " + "with reason no_named_parent") + if parent is not None and val["exclusion_reason"] == "no_named_parent": + warn("exclusion_reason", "no_named_parent but a parent is entered; check") + + d = val["decision"] + if d == "include": + for f in ("direction_basis", "relationship_type", "characterization"): + required(f) + if d == "exclude": + required("exclusion_reason") + elif val["exclusion_reason"] is not None: + err("exclusion_reason", "only allowed when decision is exclude") + if d == "ambiguous": + required("ambiguity_note") + elif val["ambiguity_note"] is not None: + err("ambiguity_note", "only allowed when decision is ambiguous") + + confounders = [] + if val["confounders"] is not None: + confounders = val["confounders"].split(";") + for t in confounders: + if not CONFOUNDER_TOKEN.match(t): + err("confounders", f"'{t}' is not a snake_case token " + "(separate tokens with ';', no spaces)") + + problems.extend(p) + if not any(x.error for x in p): + records.append({ + "schema_version": SCHEMA_VERSION, + "extractor": extractor, + "record_id": rid, + "source": val["source"], + "location": val["location"], + "evidence": val["evidence"], + "cited_primary_study": val["cited_primary_study"], + "parent_as_published": parent, + "target_as_published": val["target_as_published"], + "composition_type": {"parent": val["parent_composition_type"], + "target": val["target_composition_type"]}, + "direction_basis": val["direction_basis"], + "relationship_type": val["relationship_type"], + "characterization": val["characterization"], + "confounders": confounders, + "decision": d, + "exclusion_reason": val["exclusion_reason"], + "ambiguity_note": val["ambiguity_note"], + "parent_normalized": None, + "target_normalized": None, + }) + return problems, records + + +def sort_key(rec): + m = RECORD_ID.match(rec["record_id"]) + return (m.group(1), int(m.group(2)), int(m.group(3))) + + +def to_jsonl(records) -> bytes: + lines = [] + for r in sorted(records, key=sort_key): + ordered = {k: r[k] for k in OUTPUT_KEYS} + lines.append(json.dumps(ordered, ensure_ascii=False, separators=(",", ":"))) + return ("\n".join(lines) + "\n").encode("utf-8") if lines else b"" + + +# ---------------------------------------------------------------- commands +def run_validate(path: Path, extractor: str): + problems, records = validate_rows(read_rows(path), extractor) + for p in problems: + print(p) + errors = sum(p.error for p in problems) + warnings = len(problems) - errors + print(f"\n{len(records)} valid record(s); {errors} error(s); {warnings} warning(s)") + return problems, records, errors + + +def run_convert(src: Path, out: Path, extractor: str): + if out.exists(): + sys.exit(f"Refusing to overwrite existing file: {out}") + _, records, errors = run_validate(src, extractor) + if errors: + sys.exit("Conversion stopped: fix every ERROR first. Nothing was written.") + data = to_jsonl(records) + out.write_bytes(data) + counts = {d: sum(r["decision"] == d for r in records) for d in sorted(DECISIONS)} + report = { + "schema_version": SCHEMA_VERSION, + "extractor": extractor, + "input_xlsx": src.name, + "input_xlsx_sha256": sha256_file(src), + "output_jsonl": out.name, + "output_jsonl_sha256": sha256_file(out), + "record_count": len(records), + "decision_counts": counts, + "source_counts": {s: sum(r["source"] == s for r in records) for s in sorted(SOURCES)}, + } + rep = out.with_suffix(out.suffix + ".report.json") + if rep.exists(): + sys.exit(f"JSONL written, but report already exists and was not overwritten: {rep}") + rep.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", newline="\n") + print(json.dumps(report, indent=2, ensure_ascii=False)) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + t = sub.add_parser("template"); t.add_argument("out", type=Path) + v = sub.add_parser("validate"); v.add_argument("src", type=Path) + v.add_argument("--extractor", choices=sorted(EXTRACTORS), default="founder") + c = sub.add_parser("convert"); c.add_argument("src", type=Path); c.add_argument("out", type=Path) + c.add_argument("--extractor", choices=sorted(EXTRACTORS), default="founder") + a = ap.parse_args(argv) + if a.cmd == "template": + write_template(a.out) + elif a.cmd == "validate": + _, _, errors = run_validate(a.src, a.extractor) + sys.exit(1 if errors else 0) + else: + run_convert(a.src, a.out, a.extractor) + + +if __name__ == "__main__": + main() diff --git a/docs/findings/rrb-01/tools/rrb01_extraction_entry.xlsx b/docs/findings/rrb-01/tools/rrb01_extraction_entry.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ff9a22d30973d0ca533e882b85e86c543cbaf833 GIT binary patch literal 92907 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 Q#zW~v9u*>c Date: Mon, 9 Nov 2026 20:39:13 +0530 Subject: [PATCH 2/2] RRB-01 tools: skip tests when openpyxl is unavailable --- docs/findings/rrb-01/tools/test_rrb01_extract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/findings/rrb-01/tools/test_rrb01_extract.py b/docs/findings/rrb-01/tools/test_rrb01_extract.py index b4bfd83..1b02978 100644 --- a/docs/findings/rrb-01/tools/test_rrb01_extract.py +++ b/docs/findings/rrb-01/tools/test_rrb01_extract.py @@ -3,7 +3,8 @@ from pathlib import Path import pytest -from openpyxl import load_workbook +openpyxl = pytest.importorskip("openpyxl") +load_workbook = openpyxl.load_workbook import rrb01_extract as tool