seqcolyte / extract /builder.py
seqmachines's picture
Deploy Seqcolyte Studio
ff34739 verified
Raw
History Blame Contribute Delete
16.7 kB
"""Assemble the consolidated ``spec/10x_3p_v3.json`` from a parsed protocol.
Flow: parse HTML -> cross-check every parsed sequence against ``verified_constants`` (fail loudly
on mismatch) -> assemble oligos + final_library + read_structure with an honest evidence chain ->
schema-validate -> emit canonical (byte-reproducible) JSON.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from seqcolyte.dna import homopolymer, revcomp
from seqcolyte.spec.loader import validate_spec
from extract.html_parser import ParsedProtocol, parse_protocol
from extract.verified_constants import CITATIONS, DERIVED, VERIFIED, citation_urls
__all__ = ["build_spec", "to_canonical_json", "BUILDER_VERSION", "DEFAULT_HTML", "DEFAULT_OUT"]
BUILDER_VERSION = "1.0"
SPEC_ID = "10x_3p_v3"
RETRIEVED_DATE = "2026-07-07"
_REPO = Path(__file__).resolve().parents[1]
DEFAULT_HTML = _REPO / "protocols" / "10xChromium3.html"
DEFAULT_OUT = _REPO / "spec" / "10x_3p_v3.json"
POLYT_LEN = 30 # (T)30 on the bead oligo, per the source diagram
# key -> (oligo_id, name, role, kind, provenance, xcheck_constant_key, aliases, notes)
_OLIGO_BUILD = [
("beads_oligo_dt", "oligo_beads_oligo_dt", "Beads-oligo-dT", "oligo", "assembled", "reagent",
"r1_partial_handle", [], None),
("tso", "oligo_template_switching_oligo_tso", "Template Switching Oligo (TSO)", "oligo",
"assembled", "reagent", "tso", [], "Terminal rGrGrG (riboguanosines); parsed as GGG."),
("cdna_forward_primer", "oligo_cdna_forward_primer", "cDNA Forward primer", "primer", "single",
"document", "cdna_forward_primer", [], None),
("cdna_reverse_primer", "oligo_cdna_reverse_primer", "cDNA Reverse primer", "primer", "single",
"reagent", "cdna_reverse_primer", [], "v3/v3.1/v4 variant."),
("truseq_read1_primer", "oligo_illumina_truseq_read_1_primer", "Illumina TruSeq Read 1 primer",
"primer", "assembled", "document", "truseq_read1_primer", [], None),
("truseq_read2_primer", "oligo_illumina_truseq_read_2_primer", "Illumina TruSeq Read 2 primer",
"primer", "single", "document", "truseq_read2_primer", [], None),
("truseq_adapter", "oligo_truseq_adapter", "TruSeq adapter", "adapter", "double_stranded",
"document", None, ["TruSeq adapter forward", "TruSeq adapter reverse"],
"Double-stranded with a T overhang; v3/v3.1/v4 variant."),
("library_pcr_primer_1", "oligo_library_pcr_primer_1", "Library PCR primer 1", "primer",
"assembled", "document", "p5", [], None),
("library_pcr_primer_2", "oligo_library_pcr_primer_2", "Library PCR primer 2", "primer",
"assembled", "document", "p7", [], None),
("sample_index_seq_primer", "oligo_sample_index_sequencing_primer",
"Sample index sequencing primer", "primer", "single", "document",
"sample_index_seq_primer", [], None),
("p5", "oligo_illumina_p5_adapter", "Illumina P5 adapter", "adapter", "single", "document",
"p5", [], None),
("p7", "oligo_illumina_p7_adapter", "Illumina P7 adapter", "adapter", "single", "document",
"p7", [], None),
]
# derived oligos (revcomp) — added so the read-through chain resolves from `oligos` uniformly.
# (oligo_id, name, role, derived_key, derivation, notes)
_DERIVED_BUILD = [
("oligo_r2_readthrough_adapter", "Read 2 read-through adapter", "read_through_adapter",
"r2_readthrough_adapter", "revcomp(oligo_illumina_truseq_read_2_primer)",
"3' adapter observed as R2 read-through when the insert is shorter than the read."),
("oligo_r1_readinto_adapter", "Short-insert R2 read-into adapter", "read_through_adapter",
"r1_readinto_adapter", "revcomp(oligo_illumina_truseq_read_1_primer)",
"Adapter a short-insert R2 reads into, past poly(A) + reverse-complemented barcode/UMI."),
("oligo_p5_rc", "Illumina P5 adapter (reverse complement)", "adapter_rc", "p5_rc",
"revcomp(oligo_illumina_p5_adapter)", "Terminal 3' segment of a fully read-through R2."),
]
_ADAPTER_SECTION = "Adapter and primer sequences"
_FINAL_SECTION = "Step-by-step library generation / (8) Final library structure — V3, V3.1 & V4"
def _token_len(seq: str, token: str) -> int:
m = re.search(rf"\[{token}:(\d+)\]", seq)
if not m:
raise ValueError(f"token [{token}:N] not found in {seq!r}")
return int(m.group(1))
def _evidence(constant_key: str | None, locator: str) -> list[dict]:
return [{
"source_doc": "scg_10xChromium3",
"locator": locator,
"verified_against": citation_urls(constant_key) if constant_key else [],
}]
def _cross_check(parsed: ParsedProtocol) -> None:
"""Assert every parsed sequence matches the verified oracle; raise loudly otherwise."""
o = parsed.oligos
def eq(key: str, got, exp) -> None:
if got != exp:
raise ValueError(f"cross-check failed for {key}: parsed {got!r} != verified {exp!r}")
for key in ("tso", "cdna_forward_primer", "cdna_reverse_primer", "truseq_read1_primer",
"truseq_read2_primer", "sample_index_seq_primer", "p5", "p7"):
eq(key, o[key], VERIFIED[key])
eq("truseq_adapter_fwd", o["truseq_adapter"]["fwd"], VERIFIED["truseq_adapter_fwd"])
eq("truseq_adapter_rev", o["truseq_adapter"]["rev"], VERIFIED["truseq_adapter_rev"])
if not o["beads_oligo_dt"].startswith(VERIFIED["r1_partial_handle"]):
raise ValueError("beads oligo does not start with the R1 partial handle")
if not o["library_pcr_primer_1"].startswith(VERIFIED["p5"]):
raise ValueError("Library PCR primer 1 does not start with P5")
if not o["library_pcr_primer_2"].startswith(VERIFIED["p7"]):
raise ValueError("Library PCR primer 2 does not start with P7")
# derived revcomp identities (also asserted at import time in verified_constants)
if DERIVED["r2_readthrough_adapter"] != revcomp(VERIFIED["truseq_read2_primer"]):
raise ValueError("r2_readthrough_adapter != revcomp(truseq_read2_primer)")
if DERIVED["r1_readinto_adapter"] != revcomp(VERIFIED["truseq_read1_primer"]):
raise ValueError("r1_readinto_adapter != revcomp(truseq_read1_primer)")
if DERIVED["p5_rc"] != revcomp(VERIFIED["p5"]):
raise ValueError("p5_rc != revcomp(p5)")
def _components(key: str, parsed: ParsedProtocol, cb_len: int, umi_len: int, idx_len: int) -> list[dict]:
o = parsed.oligos
if key == "beads_oligo_dt":
return [
{"name": "cDNA forward primer", "sequence": VERIFIED["cdna_forward_primer"], "role": "primer"},
{"name": f"[CELL_BARCODE:{cb_len}]", "sequence": f"[CELL_BARCODE:{cb_len}]", "role": "cell_barcode"},
{"name": f"[UMI:{umi_len}]", "sequence": f"[UMI:{umi_len}]", "role": "umi"},
{"name": "polyT", "sequence": homopolymer("T", POLYT_LEN) + "VN", "role": "polyT"},
]
if key == "tso":
return [{"name": "cDNA reverse primer", "sequence": VERIFIED["cdna_reverse_primer"], "role": "primer"}]
if key == "truseq_read1_primer":
return [{"name": "cDNA forward primer", "sequence": VERIFIED["cdna_forward_primer"], "role": "primer"}]
if key == "truseq_adapter":
return [
{"name": "TruSeq adapter forward", "sequence": o["truseq_adapter"]["fwd"], "role": "forward_strand"},
{"name": "TruSeq adapter reverse", "sequence": o["truseq_adapter"]["rev"], "role": "reverse_strand"},
]
if key == "library_pcr_primer_1":
return [{"name": "Illumina P5 adapter", "sequence": VERIFIED["p5"], "role": "adapter"}]
if key == "library_pcr_primer_2":
return [
{"name": "Illumina P7 adapter", "sequence": VERIFIED["p7"], "role": "adapter"},
{"name": f"[SAMPLE_INDEX:{idx_len}]", "sequence": f"[SAMPLE_INDEX:{idx_len}]", "role": "sample_index"},
]
return []
def _build_oligos(parsed: ParsedProtocol, cb_len: int, umi_len: int, idx_len: int) -> list[dict]:
oligos: list[dict] = []
for key, oid, name, role, kind, prov, xkey, aliases, notes in _OLIGO_BUILD:
if kind == "double_stranded":
sequence = None
else:
sequence = parsed.oligos[key]
oligos.append({
"oligo_id": oid,
"name": name,
"aliases": aliases,
"role": role,
"kind": kind,
"sequence": sequence,
"direction": "5_to_3",
"components": _components(key, parsed, cb_len, umi_len, idx_len),
"provenance": prov,
"derivation": None,
"sequence_source": "parsed_scg_html",
"evidence": _evidence(xkey, _ADAPTER_SECTION),
"notes": notes,
})
for oid, name, role, dkey, derivation, notes in _DERIVED_BUILD:
oligos.append({
"oligo_id": oid,
"name": name,
"aliases": [],
"role": role,
"kind": "single",
"sequence": DERIVED[dkey],
"direction": "5_to_3",
"components": [],
"provenance": "document",
"derivation": derivation,
"sequence_source": "derived_revcomp",
"evidence": _evidence(dkey, "Derived (reverse complement) — see base oligo"),
"notes": notes,
})
return oligos
def _build_final_library(parsed: ParsedProtocol, cb_len: int, umi_len: int, idx_len: int) -> dict:
p5 = VERIFIED["p5"]
r1 = VERIFIED["truseq_read1_primer"]
s5_lib = r1[len("ACAC"):] # library context: P5's 3' ACAC overlaps the R1 primer's 5' ACAC
polyt = homopolymer("T", POLYT_LEN)
r2_adapter = DERIVED["r2_readthrough_adapter"]
p7_rc = DERIVED["p7_rc"]
cb, umi, si = f"[CELL_BARCODE:{cb_len}]", f"[UMI:{umi_len}]", f"[SAMPLE_INDEX:{idx_len}]"
annotated = f"{p5}{s5_lib}{cb}{umi}{polyt}VN[CDNA]{r2_adapter}{si}{p7_rc}"
# scoring placeholders (cDNA omitted from scoring, per the groundtruth normalization policy)
scored = (f"{p5}{s5_lib}{'#' * cb_len}{'~' * umi_len}{polyt}VN{r2_adapter}{'@' * idx_len}{p7_rc}")
return {
"source_label": parsed.final_library["source_label"],
"annotated_library_sequence": annotated,
"library_sequence": scored,
"strands": [
{"direction": "5_to_3",
"source_html": parsed.final_library["strand_5to3_html"],
"source_sequence": parsed.final_library["strand_5to3_text"]},
{"direction": "3_to_5",
"source_html": parsed.final_library["strand_3to5_html"],
"source_sequence": parsed.final_library["strand_3to5_text"]},
],
"annotation_lines": [
"Illumina P5 | TruSeq Read 1 | cell barcode | UMI | poly(dT)VN | cDNA | "
"TruSeq Read 2 | sample index (i7) | Illumina P7",
],
"evidence": _evidence("r2_readthrough_adapter", _FINAL_SECTION),
}
def _build_read_structure(parsed: ParsedProtocol, cb_len: int, umi_len: int, idx_len: int,
r2_cycles: int) -> dict:
r1_cycles = parsed.sequencing.get("R1_cycles", cb_len + umi_len)
reads = [
{
"read": "R1",
"primer": "Illumina TruSeq Read 1 primer",
"template": "bottom",
"cycles": r1_cycles,
"segments": [
{"name": "cell_barcode", "type": "barcode", "order": 0, "length": cb_len,
"scored": True, "provenance": None, "whitelist_ref": "cell_barcode_3M_feb2018",
"constant_ref": None, "notes": None},
{"name": "umi", "type": "umi", "order": 1, "length": umi_len, "scored": True,
"provenance": None, "whitelist_ref": None, "constant_ref": None, "notes": None},
],
},
{
"read": "I1",
"primer": "Sample index sequencing primer",
"template": "bottom",
"cycles": idx_len,
"segments": [
{"name": "sample_index_i7", "type": "index", "order": 0, "length": idx_len,
"scored": False, "provenance": None, "whitelist_ref": None, "constant_ref": None,
"notes": "v3/v3.1 single index (SI-GA)"},
],
},
{
"read": "R2",
"primer": "Illumina TruSeq Read 2 primer",
"template": "top",
"cycles": r2_cycles,
"segments": [
{"name": "cdna_insert", "type": "insert", "order": 0, "length_range": [1, r2_cycles],
"scored": True, "provenance": None, "whitelist_ref": None, "constant_ref": None,
"notes": "sequenced from TSO/5' end toward poly(A)/3' end; read length preserved by the simulator"},
],
"readthrough_chain": [
{"name": "tso_5prime", "type": "constant", "constant_ref": "oligo_template_switching_oligo_tso",
"notes": "adapter-dimer / short-insert R2 leads with the TSO (HTML Step (5)-(6) Product 1)"},
{"name": "cdna_short", "type": "insert", "notes": "residual insert 0-30 nt (pure dimer: 0)"},
{"name": "polyA", "type": "polyA", "base": "A", "notes": "variable-length poly(A)"},
{"name": "umi_rc", "type": "umi", "derivation": "revcomp(umi)"},
{"name": "cbc_rc", "type": "barcode", "derivation": "revcomp(cell_barcode)"},
{"name": "r1_primer_rc", "type": "constant", "constant_ref": "oligo_r1_readinto_adapter"},
{"name": "p5_rc", "type": "constant", "constant_ref": "oligo_p5_rc"},
{"name": "polyG_pad", "type": "constant", "base": "G",
"notes": "two-color (NextSeq/NovaSeq) no-signal pad to read length; spuriously high quality"},
],
},
]
return {"reads": reads}
def _build_source_docs() -> list[dict]:
docs = []
for doc_id, meta in CITATIONS.items():
docs.append({
"doc_id": doc_id,
"title": meta["title"],
"url": meta.get("url"),
"path": meta.get("path"),
"retrieved_date": RETRIEVED_DATE if doc_id == "scg_10xChromium3" else None,
})
return docs
def build_spec(html_path: str | Path = DEFAULT_HTML) -> dict:
"""Parse the HTML, cross-check, and assemble the consolidated spec dict."""
parsed = parse_protocol(html_path)
_cross_check(parsed)
cb_len = _token_len(parsed.oligos["beads_oligo_dt"], "CELL_BARCODE")
umi_len = _token_len(parsed.oligos["beads_oligo_dt"], "UMI")
idx_len = _token_len(parsed.oligos["library_pcr_primer_2"], "SAMPLE_INDEX")
r2_cycles = 90 # 10x recommendation; source diagram shows 98; pbmc_1k_v3 data is 91
spec = {
"schema_version": "seqcolyte.spec.v1",
"spec_id": SPEC_ID,
"assay": "10x Chromium Single Cell 3' Gene Expression",
"chemistry_version": "v3/v3.1",
"platform": "illumina",
"platform_params": {
"two_color_chemistry": True,
"dark_base": "G",
"polyA_base": "A",
"index_scheme": "single",
"i7_length": idx_len,
"i5_length": None,
"read_lengths": {"R1": cb_len + umi_len, "R2": r2_cycles, "I1": idx_len},
},
"source_docs": _build_source_docs(),
"oligos": _build_oligos(parsed, cb_len, umi_len, idx_len),
"final_library": _build_final_library(parsed, cb_len, umi_len, idx_len),
"read_structure": _build_read_structure(parsed, cb_len, umi_len, idx_len, r2_cycles),
"library_generation": parsed.library_generation,
"whitelists": {
"cell_barcode_3M_feb2018": {
"name": "3M-february-2018",
"path": "whitelists/3M-february-2018.txt.gz",
"md5": None,
"md5_provenance": "computed_local_no_official_checksum",
"source_url": "https://raw.githubusercontent.com/f0t1h/3M-february-2018/master/3M-february-2018.txt.gz",
"source_note": "community mirror; official file ships only inside Cell Ranger; no vendor checksum published",
"size_bytes_gz": 18350152,
"count": 6794880,
"length": cb_len,
"retrieved_date": None,
}
},
"build": {
"builder_version": BUILDER_VERSION,
"deterministic": True,
"source_html_sha256": parsed.source_html_sha256,
},
}
validate_spec(spec)
return spec
def to_canonical_json(spec: dict) -> bytes:
"""Canonical, byte-reproducible serialization (fixed insertion order, no timestamps)."""
return (json.dumps(spec, indent=2, ensure_ascii=True) + "\n").encode("utf-8")