Cai
2026-08-09 7cade98245b2b6a6d7eca889ffce4f6a1b87ac7a
research(new-energy): add batch automation utilities
9 files added
7210 ■■■■■ changed files
ana-data/img/新能源案例/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/.gitkeep 1 ●●●● patch | view | raw | blame | history
ana-data/tools/newenergy_batch001_accept.py 370 ●●●●● patch | view | raw | blame | history
ana-data/tools/newenergy_batch001_repair.py 1731 ●●●●● patch | view | raw | blame | history
ana-data/tools/newenergy_batch001_repair004.py 1258 ●●●●● patch | view | raw | blame | history
ana-data/tools/newenergy_batch001_repair005.py 871 ●●●●● patch | view | raw | blame | history
ana-data/tools/newenergy_batch002_accept.py 509 ●●●●● patch | view | raw | blame | history
ana-data/tools/newenergy_batch002_build.py 2210 ●●●●● patch | view | raw | blame | history
ana-data/tools/newenergy_batch002_query_probe.py 159 ●●●●● patch | view | raw | blame | history
ana-data/tools/verify_newenergy_batch002_acceptance.py 101 ●●●●● patch | view | raw | blame | history
ana-data/img/新能源案例/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/.gitkeep
New file
@@ -0,0 +1 @@
ana-data/tools/newenergy_batch001_accept.py
New file
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""Apply the independently approved terminal status sync for NEWENERGY BATCH-001.
This is an L0 state-only transition.  It changes review/document/package state,
recomputes receipt/output/artifact hashes affected by that state transition, and
creates a terminal acceptance receipt.  It does not change source content,
evidence text, eligibility, ranking, mappings, or research conclusions.
"""
from __future__ import annotations
import csv
import json
import re
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import newenergy_batch001_repair as base
import newenergy_batch001_repair004 as r4
TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
BATCH_ID = "BATCH-001"
RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001-BATCH-001-001"
FINAL_AUDIT = "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH001-EXECUTION-OUTPUT-REPAIR005-REREVIEW-20260806-001"
PARENT_STATUS = "COMPLETED_ACCEPTED_AND_CLOSED"
ACCEPTED = "ACCEPTED_BY_INDEPENDENT_REVIEW"
ARTIFACT_STATUS = "FINAL_ACCEPTED_BY_INDEPENDENT_REVIEW"
TOOL_VERSION = "REPAIR-005"
PROJECT_ROOT = Path(__file__).resolve().parents[2]
INDUSTRY_ROOT = PROJECT_ROOT / "ana-data/cases/新能源案例"
CASE_ROOT = INDUSTRY_ROOT / CASE_ID
CASE_OUTPUTS = CASE_ROOT / "outputs"
CASE_MANIFEST = CASE_ROOT / "manifest"
RESULT_ROOT = PROJECT_ROOT / "ana-data/result/新能源案例" / CASE_ID
ACCEPTANCE_RECORD = RESULT_ROOT / "acceptance_record.md"
ACCEPTANCE_VALIDATION = CASE_MANIFEST / "acceptance_validation_receipt.json"
def sha256_file(path: Path) -> str:
    return r4.sha256_file(path)
def stable_accepted_at() -> str:
    if ACCEPTANCE_RECORD.exists():
        text = ACCEPTANCE_RECORD.read_text(encoding="utf-8")
        match = re.search(r"^- `accepted_at=([^`]+)`$", text, flags=re.M)
        if match:
            return match.group(1)
    return datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()
def read_csv_with_header(path: Path) -> tuple[list[str], list[dict[str, str]]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        return list(reader.fieldnames or []), list(reader)
def canonical_receipt_hash(row: dict[str, str]) -> str:
    core = {key: value for key, value in row.items() if key != "receipt_sha256"}
    payload = json.dumps(core, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return r4.sha256_bytes(payload)
def sync_csv_review_statuses() -> dict[str, int]:
    counters: Counter[str] = Counter()
    artifact_manifest = INDUSTRY_ROOT / "manifest" / "artifact_manifest.csv"
    for path in sorted(INDUSTRY_ROOT.rglob("*.csv")):
        if path.resolve() == artifact_manifest.resolve():
            continue
        headers, rows = read_csv_with_header(path)
        if "review_status" not in headers:
            continue
        for row in rows:
            row["review_status"] = ACCEPTED
            if path.name == "human_doc_validation_receipt.csv":
                row["document_status"] = ACCEPTED
            if path.name == "output_manifest.csv":
                row["data_status"] = "FINAL_ACCEPTED"
            if "receipt_sha256" in headers:
                row["receipt_sha256"] = canonical_receipt_hash(row)
        base.write_csv(path, headers, rows)
        counters["csv_files"] += 1
        counters["csv_rows"] += len(rows)
        counters[path.name] += len(rows)
    return dict(counters)
def replace_required(path: Path, old: str, new: str, *, minimum: int = 1) -> int:
    text = path.read_text(encoding="utf-8")
    count = text.count(old)
    if count < minimum and new not in text:
        raise RuntimeError(f"required acceptance transition marker missing in {path}: {old!r}")
    if count:
        path.write_text(text.replace(old, new), encoding="utf-8", newline="\n")
    return count
def sync_markdown_statuses() -> dict[str, int]:
    counts: Counter[str] = Counter()
    for path in sorted(CASE_OUTPUTS.rglob("*.md")):
        counts["output_status_markers"] += replace_required(
            path, "> 状态:`DRAFT_FOR_REVIEW`", f"> 状态:`{ACCEPTED}`"
        )
        counts["output_files"] += 1
    summary = CASE_OUTPUTS / "summary.md"
    old = (
        "输出仍为 `DRAFT_FOR_REVIEW`。在独立执行/输出审核 PASS 前,不标记完成、"
        "不回写父级终态、不对外宣称体系成果已经验收。"
    )
    new = (
        f"本批执行/输出已由独立审核 `{FINAL_AUDIT}` 以 `PASS / 0 / 0` 接受,"
        f"父级状态已回写为 `{PARENT_STATUS}`。接受仅覆盖本 case/BATCH/run 的现有公开资料成果,"
        "不放行下一批、范围扩展、完整覆盖声明、估值、行情、交易、收益、市场反向补漏或核电敏感内容。"
    )
    counts["summary_terminal_paragraph"] += replace_required(summary, old, new)
    batch_summary = CASE_MANIFEST / "batch_summary.md"
    counts["candidate_count_fix"] += replace_required(
        batch_summary,
        "candidate_state_count=INCLUDED_T1:16,INCLUDED_T2:16,HELD_BY_EVIDENCE_GAP:4479",
        "candidate_state_count=INCLUDED_T1:16,INCLUDED_T2:16,ELIGIBLE_NOT_SELECTED_BATCH001:40,HELD_BY_EVIDENCE_GAP:4479",
    )
    counts["batch_output_status"] += replace_required(
        batch_summary, "output_status=DRAFT_FOR_REVIEW", "output_status=FINAL_ACCEPTED"
    )
    counts["batch_review_status"] += replace_required(
        batch_summary, "review_status=PENDING_REPAIR005_FOCUSED_REREVIEW", f"review_status={ACCEPTED}"
    )
    result_index = RESULT_ROOT / "result_index.md"
    counts["result_header"] += replace_required(
        result_index, "> 状态:`DRAFT_FOR_REVIEW`", f"> 状态:`{ACCEPTED}`"
    )
    counts["result_current"] += replace_required(
        result_index, "当前状态:`DRAFT_FOR_REVIEW`", f"当前状态:`{ACCEPTED}`"
    )
    counts["result_review"] += replace_required(
        result_index, "执行/输出独立审核:`PENDING`", f"执行/输出独立审核:`PASS`(`{FINAL_AUDIT}`)"
    )
    counts["result_parent"] += replace_required(
        result_index, "父级终态回写:`NOT_ALLOWED_BEFORE_REVIEW_PASS`", f"父级终态回写:`{PARENT_STATUS}`"
    )
    return dict(counts)
def rebuild_output_manifest() -> list[dict[str, str]]:
    path = CASE_MANIFEST / "output_manifest.csv"
    headers, rows = read_csv_with_header(path)
    for row in rows:
        output = PROJECT_ROOT / row["output_path"]
        if not output.exists():
            raise FileNotFoundError(output)
        row["output_sha256"] = sha256_file(output)
        row["data_status"] = "FINAL_ACCEPTED"
        row["review_status"] = ACCEPTED
    base.write_csv(path, headers, rows)
    return rows
def write_acceptance_record(accepted_at: str, output_manifest_hash: str) -> None:
    RESULT_ROOT.mkdir(parents=True, exist_ok=True)
    text = f"""# {CASE_ID} 正式接受记录
- `task_id={TASK_ID}`
- `case_id={CASE_ID}`
- `batch_id={BATCH_ID}`
- `run_id={RUN_ID}`
- `status={ACCEPTED}`
- `accepted_at={accepted_at}`
- `final_audit={FINAL_AUDIT}`
- `audit_result=PASS/0/0/0`
- `closed_blockers=B1-R004-A,B1-R004-B`
- `parent_status={PARENT_STATUS}`
- `accepted_output_manifest=ana-data/cases/新能源案例/{CASE_ID}/manifest/output_manifest.csv`
- `accepted_output_manifest_sha256={output_manifest_hash.upper()}`
- `governance=OK_PROJECTS1_WARNINGS0`
## 接受范围
本记录接受本 case/BATCH/run 的锂电、光伏、风电、核电四赛道基础行业图谱、16 份子行业正文、三类顶层视图、4,551 条唯一候选映射账本、32 条正式 selected 公司—赛道映射、786/786 份 source/conversion、82 条 evidence fact、110 条结论映射和 24 项 output manifest。候选终态为 `INCLUDED_T1=16 / INCLUDED_T2=16 / ELIGIBLE_NOT_SELECTED_BATCH001=40 / HELD_BY_EVIDENCE_GAP=4479`。
执行/输出独立复审 `{FINAL_AUDIT}` 已关闭 B1-R004-A/B,并给出 `PASS / blocker 0 / non-blocking 0`;此前设计及执行审核的所有 HOLD/PASS 记录和冻结哈希继续保留。审核后仅执行获准的 L0 状态同步,未修改来源内容、证据原文、资格判断、机械排序、结论强度或研究边界。
## 父级关闭与边界
项目管理员已在 `ana-doc/案例总纲.md` append-only 回写 `{PARENT_STATUS}`。本接受不放行下一批或扩域;继续保持 `primary_region=MAINLAND_CHINA`、全球/境外分账、`coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY`,不形成完整覆盖、估值、行情、交易、收益、市场反向补漏或核电敏感结论。
"""
    ACCEPTANCE_RECORD.write_text(text, encoding="utf-8", newline="\n")
def count_csv(path: Path) -> tuple[int, list[dict[str, str]]]:
    _, rows = read_csv_with_header(path)
    return len(rows), rows
def write_acceptance_validation(accepted_at: str, output_rows: list[dict[str, str]]) -> dict[str, Any]:
    candidate_path = INDUSTRY_ROOT / "extracted" / "company_track_candidate_ledger.csv"
    evidence_path = INDUSTRY_ROOT / "evidence" / "evidence_fact_table.csv"
    case_map_path = CASE_ROOT / "evidence" / "case_evidence_map.csv"
    role_path = INDUSTRY_ROOT / "extracted" / "candidate_role_adjudication_receipt.csv"
    page_path = INDUSTRY_ROOT / "extracted" / "candidate_page_qualification_receipt.csv"
    source_path = INDUSTRY_ROOT / "manifest" / "source_document.csv"
    conversion_path = INDUSTRY_ROOT / "manifest" / "conversion_status.csv"
    _, candidates = count_csv(candidate_path)
    source_count, _ = count_csv(source_path)
    conversion_count, _ = count_csv(conversion_path)
    evidence_count, _ = count_csv(evidence_path)
    map_count, _ = count_csv(case_map_path)
    _, roles = count_csv(role_path)
    _, pages = count_csv(page_path)
    candidate_states = dict(sorted(Counter(row["candidate_state"] for row in candidates).items()))
    errors: list[str] = []
    expected_states = {
        "ELIGIBLE_NOT_SELECTED_BATCH001": 40,
        "HELD_BY_EVIDENCE_GAP": 4479,
        "INCLUDED_T1": 16,
        "INCLUDED_T2": 16,
    }
    if candidate_states != expected_states:
        errors.append(f"candidate state mismatch: {candidate_states}")
    if (source_count, conversion_count, evidence_count, map_count, len(output_rows)) != (786, 786, 82, 110, 24):
        errors.append(
            f"accepted package counts mismatch: source={source_count} conversion={conversion_count} "
            f"evidence={evidence_count} map={map_count} output={len(output_rows)}"
        )
    for label, rows in (("role", roles), ("page", pages)):
        invalid = sum(row.get("receipt_sha256") != canonical_receipt_hash(row) for row in rows)
        if invalid:
            errors.append(f"{label} receipt hash mismatch={invalid}")
    if any(row["review_status"] != ACCEPTED or row["data_status"] != "FINAL_ACCEPTED" for row in output_rows):
        errors.append("output manifest terminal status mismatch")
    output_hash_errors = sum(
        row["output_sha256"] != sha256_file(PROJECT_ROOT / row["output_path"])
        for row in output_rows
    )
    if output_hash_errors:
        errors.append(f"output hash mismatch={output_hash_errors}")
    receipt = {
        "task_id": TASK_ID,
        "case_id": CASE_ID,
        "batch_id": BATCH_ID,
        "run_id": RUN_ID,
        "accepted_at": accepted_at,
        "status": ACCEPTED,
        "final_audit": FINAL_AUDIT,
        "parent_status": PARENT_STATUS,
        "transition_type": "L0_REVIEW_STATUS_AND_MANIFEST_HASH_SYNC_ONLY",
        "candidate_state_distribution": candidate_states,
        "source_count": source_count,
        "conversion_count": conversion_count,
        "evidence_fact_count": evidence_count,
        "case_map_count": map_count,
        "output_count": len(output_rows),
        "role_receipt_count": len(roles),
        "page_receipt_count": len(pages),
        "core_hashes": {
            "candidate_ledger": sha256_file(candidate_path),
            "evidence_fact_table": sha256_file(evidence_path),
            "case_evidence_map": sha256_file(case_map_path),
            "role_adjudication_receipt": sha256_file(role_path),
            "candidate_page_qualification_receipt": sha256_file(page_path),
            "output_manifest": sha256_file(CASE_MANIFEST / "output_manifest.csv"),
            "acceptance_record": sha256_file(ACCEPTANCE_RECORD),
        },
        "historical_frozen_receipt": "manifest/repair005_validation_receipt.json (pre-acceptance audit snapshot; intentionally unchanged)",
        "validation_errors": errors,
        "validation_status": "PASS" if not errors else "FAIL",
    }
    ACCEPTANCE_VALIDATION.write_text(
        json.dumps(receipt, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
        encoding="utf-8",
        newline="\n",
    )
    if errors:
        raise RuntimeError("acceptance validation failed: " + "; ".join(errors))
    return receipt
def rebuild_artifact_manifest(output_rows: list[dict[str, str]]) -> list[dict[str, str]]:
    source_rows = base.read_csv(INDUSTRY_ROOT / "manifest" / "source_document.csv")
    collected = [row.get("collected_at", "") for row in source_rows if row.get("collected_at")]
    base.COLLECTED_AT = max(collected, default="2026-08-06T00:00:00+08:00")
    base.ARTIFACT_TOOL_VERSION = TOOL_VERSION
    base.ARTIFACT_PARAMETERS_SUMMARY = (
        "B1 REPAIR005 independently accepted; terminal L0 review/document/package status sync; "
        "no source, evidence, eligibility, ranking, mapping, or conclusion change"
    )
    base.ARTIFACT_STATUS = ARTIFACT_STATUS
    audited_replay_tool = Path(__file__).with_name("newenergy_batch001_repair005.py")
    return base.build_artifact_manifest(source_rows, output_rows, audited_replay_tool)
def validate_artifact_manifest(rows: list[dict[str, str]]) -> dict[str, Any]:
    audited_replay_tool = Path(__file__).with_name("newenergy_batch001_repair005.py")
    formal = base.formal_files(audited_replay_tool)
    by_path = {row["relative_path"]: row for row in rows}
    errors: list[str] = []
    if len(by_path) != len(rows):
        errors.append("duplicate artifact relative path")
    if set(by_path) != {base.rel(path) for path in formal}:
        errors.append("artifact/formal exact-set mismatch")
    for path in formal:
        row = by_path.get(base.rel(path))
        if row is None:
            continue
        if row["file_size"] != str(path.stat().st_size) or row["sha256"] != sha256_file(path):
            errors.append(f"artifact hash mismatch: {base.rel(path)}")
            break
    if any(row["artifact_status"] != ARTIFACT_STATUS or row["tool_version"] != TOOL_VERSION for row in rows):
        errors.append("artifact terminal status/tool version mismatch")
    if errors:
        raise RuntimeError("artifact validation failed: " + "; ".join(errors))
    return {
        "artifact_exact_set": len(rows),
        "artifact_manifest_sha256": sha256_file(INDUSTRY_ROOT / "manifest" / "artifact_manifest.csv"),
        "package_exact_set_receipt_sha256": sha256_file(CASE_MANIFEST / "package_exact_set_receipt.md"),
    }
def assert_no_pending_markers() -> None:
    forbidden = ("DRAFT_FOR_REVIEW", "PENDING_REPAIR005_FOCUSED_REREVIEW", "NOT_ALLOWED_BEFORE_REVIEW_PASS")
    files = list(CASE_OUTPUTS.rglob("*.md")) + [CASE_MANIFEST / "batch_summary.md", RESULT_ROOT / "result_index.md"]
    hits = [(path, marker) for path in files for marker in forbidden if marker in path.read_text(encoding="utf-8")]
    if hits:
        raise RuntimeError(f"terminal Markdown still contains pending marker: {hits[:3]}")
def run() -> dict[str, Any]:
    accepted_at = stable_accepted_at()
    csv_sync = sync_csv_review_statuses()
    markdown_sync = sync_markdown_statuses()
    output_rows = rebuild_output_manifest()
    output_manifest_hash = sha256_file(CASE_MANIFEST / "output_manifest.csv")
    write_acceptance_record(accepted_at, output_manifest_hash)
    acceptance = write_acceptance_validation(accepted_at, output_rows)
    artifacts = rebuild_artifact_manifest(output_rows)
    artifact_validation = validate_artifact_manifest(artifacts)
    assert_no_pending_markers()
    return {
        "status": "ACCEPTED_BY_INDEPENDENT_REVIEW_AND_PARENT_CLOSED",
        "final_audit": FINAL_AUDIT,
        "parent_status": PARENT_STATUS,
        "accepted_at": accepted_at,
        "candidate_state_distribution": acceptance["candidate_state_distribution"],
        "csv_sync": csv_sync,
        "markdown_sync": markdown_sync,
        "output_count": len(output_rows),
        "output_manifest_sha256": output_manifest_hash,
        "acceptance_record_sha256": sha256_file(ACCEPTANCE_RECORD),
        "acceptance_validation_sha256": sha256_file(ACCEPTANCE_VALIDATION),
        **artifact_validation,
    }
def main() -> None:
    print(json.dumps(run(), ensure_ascii=False, sort_keys=True, indent=2))
if __name__ == "__main__":
    main()
ana-data/tools/newenergy_batch001_repair.py
New file
@@ -0,0 +1,1731 @@
#!/usr/bin/env python3
"""Rebuild the governed BATCH-001 new-energy evidence package after HOLD/3.
The script is intentionally deterministic at the transformation layer. Network
snapshots are timestamped and hashed; all downstream CSV/Markdown files are
rebuilt from those snapshots plus the already archived 38 official sources.
"""
from __future__ import annotations
import csv
import hashlib
import html
import io
import json
import math
import re
import shutil
import sys
import time
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime
from html.parser import HTMLParser
from pathlib import Path
from zoneinfo import ZoneInfo
import openpyxl  # noqa: F401 - dependency check and XLSX header validation
from pypdf import PdfReader
TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
BATCH_ID = "BATCH-001"
RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001-BATCH-001-001"
SCHEMA_VERSION = "NEWENERGY_EXTENSION_V1"
REVIEW_STATUS = "DRAFT_FOR_REVIEW"
SOURCE_CUTOFF = "2026-08-05T23:59:59+08:00"
AS_OF_DATE = "2026-08-05"
COLLECTED_AT = datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()
ARTIFACT_TOOL_VERSION = "REPAIR-005"
ARTIFACT_PARAMETERS_SUMMARY = (
    "B1 REPAIR005; reuse 729 acquired PDFs; replay 858 pairs through the "
    "company-self and target-bucket semantic role gate; clear non-eligible "
    "derived fields and dangling evidence FKs; mechanical rerank; exact-set hash coverage"
)
ARTIFACT_STATUS = "READY_FOR_REPAIR005_FOCUSED_REREVIEW"
PROJECT_ROOT = Path(__file__).resolve().parents[2]
INDUSTRY_ROOT = PROJECT_ROOT / "ana-data/cases/新能源案例"
CASE_ROOT = INDUSTRY_ROOT / CASE_ID
RESULT_ROOT = PROJECT_ROOT / f"ana-data/result/新能源案例/{CASE_ID}"
RAW_ROOT = INDUSTRY_ROOT / "raw"
CONVERTED_ROOT = INDUSTRY_ROOT / "converted"
EXTRACTED_ROOT = INDUSTRY_ROOT / "extracted"
SUPPLEMENT_ROOT = INDUSTRY_ROOT / "supplement"
EVIDENCE_ROOT = INDUSTRY_ROOT / "evidence"
MANIFEST_ROOT = INDUSTRY_ROOT / "manifest"
CASE_EVIDENCE = CASE_ROOT / "evidence"
CASE_MANIFEST = CASE_ROOT / "manifest"
CASE_OUTPUTS = CASE_ROOT / "outputs"
OLD_CASE_EVIDENCE = CASE_EVIDENCE
OLD_CASE_MANIFEST = CASE_MANIFEST
HEADERS = {
    "artifact_manifest": [
        "artifact_id", "task_id", "case_id", "batch_id", "run_id", "artifact_type",
        "industry_case", "industry_id", "subindustry_id", "company_id", "logical_path",
        "relative_path", "absolute_path", "file_name", "file_ext", "file_size", "sha256",
        "source_doc_id", "source_url", "source_collected_at", "raw_pool_path",
        "source_file_name", "detected_type", "archive_file_name",
        "extension_added_by_archive_flag", "extension_mismatch_flag", "created_at",
        "created_by", "tool_or_method", "tool_version", "parameters_summary",
        "source_snapshot_id", "artifact_status", "sensitivity_screen",
    ],
    "conversion_status": [
        "conversion_id", "task_id", "case_id", "batch_id", "run_id", "source_doc_id",
        "raw_pool_path", "raw_file_path", "raw_file_sha256", "detected_type",
        "conversion_method", "parameters_summary", "converted_text_path",
        "converted_markdown_path", "converted_path", "converted_sha256",
        "page_or_duration_count", "status", "error_code", "error_summary", "created_at",
    ],
    "universe": [
        "universe_row_id", "exchange_code", "security_code", "security_name", "legal_name",
        "listing_status", "listing_date", "board", "source_doc_id", "source_record_locator",
        "as_of_date", "source_url", "raw_path", "raw_sha256", "review_status",
    ],
    "human_receipt": [
        "validation_item_id", "task_id", "case_id", "batch_id", "run_id", "view_type",
        "output_path", "document_status", "not_applicable_reason", "evidence_boundary",
        "source_count", "evidence_count", "conclusion_count", "unknown_count",
        "review_status", "validated_by", "validated_at",
    ],
}
QUERY_CONFIG = [
    ("BATTERY", "A", "资源与主材", "正极材料"),
    ("BATTERY", "B", "电芯制造", "锂离子电池"),
    ("BATTERY", "C", "系统/部件/BMS-Pack", "电池管理系统"),
    ("BATTERY", "D", "设备与回收循环", "锂电设备"),
    ("SOLAR", "A", "硅料/硅片与材料", "光伏硅片"),
    ("SOLAR", "B", "电池片/组件", "光伏组件"),
    ("SOLAR", "C", "设备/辅材/逆变器", "光伏逆变器"),
    ("SOLAR", "D", "系统集成/电站建设运营", "光伏电站"),
    ("WIND", "A", "材料与关键零部件", "风电零部件"),
    ("WIND", "B", "整机", "风力发电机组"),
    ("WIND", "C", "塔筒/海缆/工程配套", "风电塔筒"),
    ("WIND", "D", "项目运营与运维服务", "风电场"),
    ("NUCLEAR", "A", "运营商", "核电运营"),
    ("NUCLEAR", "B", "工程/EPC", "核电工程"),
    ("NUCLEAR", "C", "核岛/常规岛主设备", "核电设备"),
    ("NUCLEAR", "D", "核级部件/材料/仪控电气", "核级阀门"),
]
TRACK_ORDER = {"BATTERY": 0, "SOLAR": 1, "WIND": 2, "NUCLEAR": 3}
BUCKET_ORDER = {(track, bucket): i for i, (track, _, bucket, _) in enumerate(QUERY_CONFIG)}
def rel(path: Path) -> str:
    return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()
def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()
def write_bytes(path: Path, data: bytes) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(data)
def write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text.replace("\r\n", "\n"), encoding="utf-8", newline="\n")
def read_csv(path: Path) -> list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as f:
        return list(csv.DictReader(f))
def write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, object]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore", quoting=csv.QUOTE_ALL)
        w.writeheader()
        for row in rows:
            w.writerow({k: "" if row.get(k) is None else row.get(k, "") for k in fieldnames})
def fetch(url: str, *, method: str = "GET", body: bytes | None = None, referer: str = "") -> bytes:
    headers = {
        "User-Agent": "Mozilla/5.0",
        "Accept": "application/json, text/plain, */*",
        "Referer": referer or url,
        "X-Requested-With": "XMLHttpRequest",
    }
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    for attempt in range(5):
        try:
            with urllib.request.urlopen(req, timeout=45) as response:
                return response.read()
        except Exception:
            if attempt == 4:
                raise
            time.sleep(1.5 * (attempt + 1))
    raise RuntimeError("unreachable")
class TextExtractor(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self.parts: list[str] = []
    def handle_data(self, data: str) -> None:
        value = re.sub(r"\s+", " ", data).strip()
        if value:
            self.parts.append(value)
def html_to_text(data: bytes) -> str:
    text = data.decode("utf-8", errors="replace")
    parser = TextExtractor()
    parser.feed(text)
    return "\n".join(parser.parts) + "\n"
def clean_html_text(value: str) -> str:
    return html.unescape(re.sub(r"<[^>]+>", "", value or "")).strip()
def clean_annual_title(title: str) -> bool:
    compact = re.sub(r"\s+", "", clean_html_text(title))
    if "2025年年度报告" not in compact:
        return False
    excluded = ["摘要", "审计", "问询", "回复", "说明", "意见", "更正公告", "英文版"]
    return not any(token in compact for token in excluded)
def ensure_roots() -> None:
    for p in [
        CONVERTED_ROOT / "official_filings", CONVERTED_ROOT / "official_market",
        CONVERTED_ROOT / "official_discovery", EXTRACTED_ROOT, SUPPLEMENT_ROOT,
        EVIDENCE_ROOT, MANIFEST_ROOT, CASE_EVIDENCE, CASE_MANIFEST,
        RAW_ROOT / "official_universe", RAW_ROOT / "official_discovery",
    ]:
        p.mkdir(parents=True, exist_ok=True)
def collect_sse_universe() -> tuple[list[dict[str, str]], Path, str]:
    endpoint = "https://query.sse.com.cn/sseQuery/commonQuery.do"
    payloads = []
    records: list[dict[str, str]] = []
    for stock_type, board in [("1", "SSE_MAIN"), ("8", "SSE_STAR")]:
        params = {
            "STOCK_TYPE": stock_type, "REG_PROVINCE": "", "CSRC_CODE": "",
            "STOCK_CODE": "", "sqlId": "COMMON_SSE_CP_GPJCTPZ_GPLB_GP_L",
            "COMPANY_STATUS": "2,4,5,7,8", "type": "inParams", "isPagination": "true",
            "pageHelp.cacheSize": "1", "pageHelp.beginPage": "1",
            "pageHelp.pageSize": "5000", "pageHelp.pageNo": "1",
        }
        url = endpoint + "?" + urllib.parse.urlencode(params)
        obj = json.loads(fetch(url, referer="https://www.sse.com.cn/assortment/stock/home/"))
        payloads.append({"request_url": url, "response": obj})
        for item in obj.get("result", []):
            code = str(item.get("A_STOCK_CODE", "")).strip()
            if not re.fullmatch(r"\d{6}", code):
                continue
            records.append({
                "exchange_code": "SSE", "security_code": code,
                "security_name": str(item.get("COMPANY_ABBR", "")).strip(),
                "legal_name": str(item.get("FULL_NAME", "")).strip(),
                "listing_status": "LISTED", "listing_date": str(item.get("LIST_DATE", "")).strip(),
                "board": board, "locator": f"STOCK_TYPE={stock_type};A_STOCK_CODE={code}",
            })
    wrapper = {
        "snapshot_id": "S-UNIVERSE-SSE-20260805", "as_of_date": AS_OF_DATE,
        "source": "上海证券交易所股票列表官方查询接口", "payloads": payloads,
    }
    path = RAW_ROOT / "official_universe/SSE_A_SHARE_UNIVERSE_20260805.json"
    write_text(path, json.dumps(wrapper, ensure_ascii=False, indent=2) + "\n")
    return records, path, "https://www.sse.com.cn/assortment/stock/home/"
def collect_szse_universe() -> tuple[list[dict[str, str]], Path, str]:
    endpoint = "https://www.szse.cn/api/report/ShowReport"
    referer = "https://www.szse.cn/market/stock/company/"
    params = {"SHOWTYPE": "xlsx", "CATALOGID": "1110x", "TABKEY": "tab1"}
    url = endpoint + "?" + urllib.parse.urlencode(params)
    raw_bytes = fetch(url, referer=referer)
    if not raw_bytes.startswith(b"PK\x03\x04"):
        raise RuntimeError("SZSE company-list download is not an XLSX/ZIP payload")
    path = RAW_ROOT / "official_universe/SZSE_A_SHARE_UNIVERSE_20260805.xlsx"
    write_bytes(path, raw_bytes)
    # The official workbook advertises an incorrect A1:A1 worksheet dimension;
    # normal mode is required for openpyxl to discover all physical rows.
    workbook = openpyxl.load_workbook(io.BytesIO(raw_bytes), read_only=False, data_only=True)
    worksheet = workbook.active
    records: list[dict[str, str]] = []
    for row_no, values in enumerate(worksheet.iter_rows(values_only=True), 1):
        code = str(values[0] or "").strip().zfill(6)
        if not re.fullmatch(r"(?:00|30)\d{4}", code):
            continue
        records.append({
            "exchange_code": "SZSE", "security_code": code,
            "security_name": "", "legal_name": "", "listing_status": "LISTED", "listing_date": "",
            "board": "SZSE_CHINEXT" if code.startswith("30") else "SZSE_MAIN",
            "locator": f"xlsx_row={row_no};security_code={code}",
        })
    return records, path, referer
def collect_bse_universe() -> tuple[list[dict[str, str]], Path, str]:
    endpoint = "https://www.bse.cn/nqxxController/nqxxCnzq.do"
    referer = "https://www.bse.cn/nq/listedcompany.html"
    payloads = []
    records: list[dict[str, str]] = []
    page = 0
    total_pages = None
    while total_pages is None or page < total_pages:
        body = urllib.parse.urlencode([
            ("page", str(page)), ("typejb", "T"), ("xxfcbj[]", "2"), ("xxzqdm", ""),
            ("sortfield", "xxzqdm"), ("sorttype", "asc"), ("callback", "cb"),
        ]).encode("ascii")
        raw = fetch(endpoint, method="POST", body=body, referer=referer).decode("utf-8")
        if not raw.startswith("cb(") or not raw.endswith(")"):
            raise RuntimeError("Unexpected BSE JSONP envelope")
        obj = json.loads(raw[3:-1])
        page_obj = obj[0]
        content = page_obj["content"]
        total_pages = int(page_obj["totalPages"])
        payloads.append({"request": urllib.parse.parse_qs(body.decode("ascii")), "response": obj})
        for item in content:
            code = str(item.get("xxzqdm", "")).strip()
            if not re.fullmatch(r"92\d{4}", code):
                continue
            records.append({
                "exchange_code": "BSE", "security_code": code,
                "security_name": str(item.get("xxzqjc", "")).strip(), "legal_name": "",
                "listing_status": "LISTED", "listing_date": str(item.get("fxssrq", "")).strip(),
                "board": "BSE", "locator": f"page={page};xxzqdm={code}",
            })
        page += 1
    wrapper = {
        "snapshot_id": "S-UNIVERSE-BSE-20260805", "as_of_date": AS_OF_DATE,
        "source": "北京证券交易所股票列表官方查询接口", "payloads": payloads,
    }
    path = RAW_ROOT / "official_universe/BSE_A_SHARE_UNIVERSE_20260805.json"
    write_text(path, json.dumps(wrapper, ensure_ascii=False, indent=2) + "\n")
    return records, path, referer
def normalize_universe() -> tuple[list[dict[str, str]], dict[str, dict[str, str]], list[dict[str, str]]]:
    sources = []
    all_records: list[dict[str, str]] = []
    for collector, source_id, org in [
        (collect_sse_universe, "S-UNIVERSE-SSE-20260805", "上海证券交易所"),
        (collect_szse_universe, "S-UNIVERSE-SZSE-20260805", "深圳证券交易所"),
        (collect_bse_universe, "S-UNIVERSE-BSE-20260805", "北京证券交易所"),
    ]:
        records, raw_path, source_url = collector()
        raw_hash = sha256_file(raw_path)
        for rec in records:
            rec.update({"source_doc_id": source_id, "source_url": source_url,
                        "raw_path": rel(raw_path), "raw_sha256": raw_hash})
        all_records.extend(records)
        sources.append({
            "doc_id": source_id, "title": f"{org}A股上市公司基准快照({AS_OF_DATE})",
            "source_org": org, "author": org, "source_url": source_url,
            "raw_path": raw_path, "raw_hash": raw_hash, "doc_type": "OFFICIAL_A_SHARE_UNIVERSE_SNAPSHOT",
            "subindustry_id": "", "sensitivity": "LEGAL_PUBLIC_SCREENED",
        })
    dedup: dict[tuple[str, str], dict[str, str]] = {}
    for rec in all_records:
        key = (rec["exchange_code"], rec["security_code"])
        if key in dedup:
            raise RuntimeError(f"Duplicate universe key: {key}")
        dedup[key] = rec
    rows = []
    for i, rec in enumerate(sorted(dedup.values(), key=lambda r: (r["exchange_code"], r["security_code"])), 1):
        rows.append({
            "universe_row_id": f"UNIV-{i:05d}", **rec, "as_of_date": AS_OF_DATE,
            "review_status": REVIEW_STATUS,
        })
    write_csv(EXTRACTED_ROOT / "a_share_universe.csv", HEADERS["universe"], rows)
    by_code: dict[str, dict[str, str]] = {}
    for rec in rows:
        code = rec["security_code"]
        if code in by_code:
            raise RuntimeError(f"Cross-exchange duplicate security code: {code}")
        by_code[code] = rec
    return rows, by_code, sources
def collect_discovery_queries(universe_by_code: dict[str, dict[str, str]]) -> tuple[dict[tuple[str, str], list[dict]], list[dict[str, str]], list[dict]]:
    endpoint = "https://www.cninfo.com.cn/new/fulltextSearch/full"
    referer = "https://www.cninfo.com.cn/new/fulltextSearch?searchType=1"
    hits: dict[tuple[str, str], list[dict]] = defaultdict(list)
    source_records: list[dict[str, str]] = []
    summaries: list[dict] = []
    for track, bucket_code, bucket, keyword in QUERY_CONFIG:
        pages = []
        page = 1
        total_pages = None
        total_records = None
        archived_records = 0
        while total_records is None or archived_records < total_records:
            params = {
                "searchkey": keyword, "sdate": "2026-03-01", "edate": "2026-05-15",
                "isfulltext": "true", "sortName": "pubdate", "sortType": "asc",
                "pageNum": str(page), "pageSize": "100", "type": "szb,cyb,hzb,kcb,bjs",
            }
            url = endpoint + "?" + urllib.parse.urlencode(params)
            response_bytes = fetch(url, referer=referer)
            obj = json.loads(response_bytes)
            if total_pages is None:
                total_pages = int(obj.get("totalpages") or 0)
                total_records = int(obj.get("totalRecordNum") or 0)
            elif int(obj.get("totalRecordNum") or 0) != total_records:
                raise RuntimeError(f"CNINFO totalRecordNum changed during pagination: {track}/{bucket_code}")
            announcements = obj.get("announcements") or []
            if not announcements and archived_records < total_records:
                raise RuntimeError(
                    f"CNINFO empty page before total reached: {track}/{bucket_code} "
                    f"page={page} archived={archived_records} total={total_records}"
                )
            # Preserve the annual-report full-text hit context used by the
            # REPAIR003 qualification attempt. CNINFO occasionally returns
            # U+FFFD inside its own snippets; make those source positions
            # explicit without retaining the replacement character itself.
            compact_announcements = []
            for ann in announcements:
                announcement_content = str(ann.get("announcementContent") or "")
                compact_announcements.append({
                    key: ann.get(key)
                    for key in [
                        "announcementId", "announcementTime", "announcementTitle",
                        "secCode", "secName", "adjunctUrl",
                    ]
                })
                compact_announcements[-1]["announcementContent_sanitized"] = announcement_content.replace(
                    "\ufffd", "<CNINFO_SOURCE_U_FFFD>"
                )
                compact_announcements[-1]["source_replacement_character_count"] = announcement_content.count("\ufffd")
            pages.append({
                "request_url": url,
                "response_byte_count": len(response_bytes),
                "response_sha256": hashlib.sha256(response_bytes).hexdigest(),
                "response": {
                    "totalpages": obj.get("totalpages"),
                    "totalRecordNum": obj.get("totalRecordNum"),
                    "announcements": compact_announcements,
                },
            })
            for ann in announcements:
                if not clean_annual_title(str(ann.get("announcementTitle", ""))):
                    continue
                code = str(ann.get("secCode", "")).strip()
                if code not in universe_by_code:
                    continue
                announcement_content = str(ann.get("announcementContent") or "")
                hit = {
                    "track": track, "bucket_code": bucket_code, "bucket": bucket,
                    "keyword": keyword, "source_doc_id": f"S-DISCOVERY-{track}-{bucket_code}-20260805",
                    "security_code": code, "announcement_id": str(ann.get("announcementId", "")),
                    "security_name": str(ann.get("secName", "")).strip(),
                    "announcement_time": str(ann.get("announcementTime", "")),
                    "announcement_title": clean_html_text(str(ann.get("announcementTitle", ""))),
                    "adjunct_url": str(ann.get("adjunctUrl", "")),
                    "announcement_content_sanitized": announcement_content.replace(
                        "\ufffd", "<CNINFO_SOURCE_U_FFFD>"
                    ),
                    "source_replacement_character_count": announcement_content.count("\ufffd"),
                }
                hits[(code, track)].append(hit)
            archived_records += len(announcements)
            page += 1
        if archived_records != total_records:
            raise RuntimeError(
                f"CNINFO archived record mismatch: {track}/{bucket_code} "
                f"archived={archived_records} total={total_records}"
            )
        wrapper = {
            "snapshot_id": f"S-DISCOVERY-{track}-{bucket_code}-20260805",
            "as_of_date": AS_OF_DATE, "source_cutoff_at": SOURCE_CUTOFF,
            "purpose": "candidate_discovery_and_uniform_annual_report_context_qualification_attempt_not_direct_business_evidence",
            "query_contract": {
                "keyword": keyword, "track_code": track, "selection_bucket": bucket,
                "sdate": "2026-03-01", "edate": "2026-05-15", "isfulltext": True,
                "sortName": "pubdate", "sortType": "asc", "pageSize": 100,
                "type": "szb,cyb,hzb,kcb,bjs",
                "annual_report_filter": "clean title contains 2025年年度报告; exclude 摘要/审计/问询/回复/说明/意见/更正公告/英文版",
                "a_share_identity_filter": "must join official SSE/SZSE/BSE as-of universe",
            },
            "server_total_record_num": total_records, "server_total_pages": total_pages,
            "server_total_pages_semantics": "LAST_FULL_PAGE_NUMBER; FINAL_REMAINDER_IS_PAGE_PLUS_ONE",
            "archived_record_count": archived_records,
            "archived_page_count": len(pages),
            "terminal_page_num": len(pages),
            "terminal_page_record_count": len(pages[-1]["response"]["announcements"]) if pages else 0,
            "termination_reason": "ARCHIVED_RECORD_COUNT_EQUALS_SERVER_TOTAL_RECORD_NUM",
            "pages": pages,
        }
        raw_path = RAW_ROOT / f"official_discovery/{track}_{bucket_code}_CNINFO_2025_AR_QUERY_20260805.json"
        write_text(raw_path, json.dumps(wrapper, ensure_ascii=False, indent=2) + "\n")
        source_id = f"S-DISCOVERY-{track}-{bucket_code}-20260805"
        raw_hash = sha256_file(raw_path)
        hit_pairs = {key for key, values in hits.items() if any(v["source_doc_id"] == source_id for v in values)}
        summary = {
            "source_doc_id": source_id, "track_code": track, "bucket_code": bucket_code,
            "selection_bucket": bucket, "keyword": keyword, "server_total_record_num": total_records,
            "server_total_pages": total_pages, "annual_report_a_share_unique_pair_hits": len(hit_pairs),
            "archived_record_count": archived_records, "archived_page_count": len(pages),
            "terminal_page_num": len(pages),
            "terminal_page_record_count": len(pages[-1]["response"]["announcements"]) if pages else 0,
            "termination_reason": "ARCHIVED_RECORD_COUNT_EQUALS_SERVER_TOTAL_RECORD_NUM",
            "raw_path": rel(raw_path), "raw_sha256": raw_hash,
        }
        summaries.append(summary)
        source_records.append({
            "doc_id": source_id, "title": f"巨潮资讯2025年年度报告全文候选发现回执:{track}/{bucket}/{keyword}",
            "source_org": "巨潮资讯网", "author": "巨潮资讯网", "source_url": referer,
            "raw_path": raw_path, "raw_hash": raw_hash, "doc_type": "OFFICIAL_DISCOVERY_QUERY_RECEIPT",
            "subindustry_id": track, "sensitivity": "LEGAL_PUBLIC_SCREENED",
        })
    return hits, source_records, summaries
def build_candidate_ledger(
    current_candidates: list[dict[str, str]], hits: dict[tuple[str, str], list[dict]],
    universe_by_code: dict[str, dict[str, str]], source_docs_by_id: dict[str, dict[str, str]],
) -> list[dict[str, str]]:
    current_by_key = {(r["security_code"], r["track_code"]): dict(r) for r in current_candidates}
    all_keys = set(current_by_key) | set(hits)
    rows: list[dict[str, str]] = []
    for code, track in all_keys:
        if (code, track) in current_by_key:
            row = current_by_key[(code, track)]
            row["candidate_discovery_channel"] = "OFFICIAL_A_SHARE_UNIVERSE_CNINFO_FULLTEXT_AND_DIRECT_REPORT_VERIFICATION"
            row["include_or_exclude_reason"] = (
                "直接业务年报页级证据通过;同桶只对ELIGIBLE对象按来源等级、暴露具体性、披露期间、"
                "年报发布日期、交易所和证券代码机械排序;未取得页级直接业务证据的查询命中保持HELD,不参与排序"
            )
            rows.append(row)
            continue
        universe = universe_by_code[code]
        pair_hits = sorted(hits[(code, track)], key=lambda x: (BUCKET_ORDER[(track, x["bucket"])], x["announcement_time"], x["announcement_id"]))
        chosen = pair_hits[0]
        source_ids = sorted({x["source_doc_id"] for x in pair_hits})
        locators = sorted({f"{x['announcement_id']}@{x['announcement_time']}" for x in pair_hits if x["announcement_id"]})
        exchange = universe["exchange_code"]
        rows.append({
            "candidate_id": f"CAND-DISC-{track}-{code}", "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID,
            "company_id": f"CN_A:{exchange}:{code}", "security_code": code,
            "security_name": universe["security_name"] or chosen.get("security_name", ""), "legal_name": universe["legal_name"],
            "exchange_code": exchange, "listed_status": "LISTED", "identity_as_of": AS_OF_DATE,
            "track_code": track, "chain_nodes": "", "candidate_discovery_channel": "CNINFO_OFFICIAL_FULLTEXT_2025_ANNUAL_REPORT_QUERY",
            "candidate_source_id": chosen["source_doc_id"], "direct_business_source_id": "",
            "direct_business_locator": "QUERY_HITS=" + ";".join(locators[:8]),
            "evidence_grade": "UNVERIFIED_QUERY_HIT", "exposure_specificity": "UNKNOWN",
            "latest_disclosed_period": "2025-12-31", "selection_bucket": chosen["bucket"],
            "candidate_state": "HELD_BY_EVIDENCE_GAP", "selection_rank": "", "tier": "",
            "tie_break_rule": "NOT_ELIGIBLE_NO_DIRECT_BUSINESS_PAGE_EVIDENCE",
            "include_or_exclude_reason": (
                f"官方A股基准内公司;2025年年度报告全文检索命中{len(pair_hits)}次(回执{';'.join(source_ids)});"
                "未完成公司—赛道页级直接业务证据验证,保持HELD,不进入T1/T2机械排序"
            ),
            "coverage_claim": "NONE_DISCOVERY_POOL_NOT_COMPLETE_COVERAGE_CLAIM", "review_status": REVIEW_STATUS,
        })
    def sort_key(row: dict[str, str]):
        state_rank = {"INCLUDED_T1": 0, "INCLUDED_T2": 1}.get(row["candidate_state"], 2)
        return (TRACK_ORDER[row["track_code"]], BUCKET_ORDER[(row["track_code"], row["selection_bucket"])], state_rank,
                int(row["selection_rank"] or 999), row["exchange_code"], row["security_code"])
    rows.sort(key=sort_key)
    seen = set()
    for row in rows:
        key = (row["company_id"], row["track_code"])
        if key in seen:
            raise RuntimeError(f"Duplicate candidate pair: {key}")
        seen.add(key)
    return rows
def apply_candidate_qualification_funnel(
    candidates: list[dict[str, str]],
    hits: dict[tuple[str, str], list[dict]],
    universe_by_code: dict[str, dict[str, str]],
    source_rows: list[dict[str, str]],
    evidence_rows: list[dict[str, str]],
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
    """Retrieve and assess official annual-report context for every pair.
    The full discovery pool receives the same replayable attempt: acquire the
    official CNINFO annual-report hit context and attachment locator, classify
    its business context, then test whether a stable page/text locator and a
    company evidence fact exist. A keyword hit is never silently promoted to
    direct business evidence.
    """
    source_by_id = {row["doc_id"]: row for row in source_rows}
    company_evidence: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for evidence in evidence_rows:
        if evidence.get("company_id") and evidence.get("doc_id"):
            company_evidence[(evidence["company_id"], evidence["doc_id"])].append(evidence)
    gate_by_pair: dict[tuple[str, str], dict[str, object]] = {}
    allowed_specificity = {
        "SEGMENT_REVENUE_OR_ASSET_DISCLOSED",
        "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
        "GENERAL_DIRECT_BUSINESS_DESCRIPTION",
    }
    direct_context_tokens = [
        "公司主要从事", "主营业务", "主要业务为", "公司从事", "公司产品",
        "公司生产", "公司业务", "业务包括", "产品包括", "本公司", "项目",
    ]
    negative_context_tokens = ["不涉及", "不从事", "无相关业务", "未从事"]
    for row in candidates:
        pair = (row["company_id"], row["track_code"])
        query_hits = hits.get((row["security_code"], row["track_code"]), [])
        direct_source_id = row.get("direct_business_source_id", "")
        evidence_matches = company_evidence.get((row["company_id"], direct_source_id), [])
        query_contexts = [
            clean_html_text(hit.get("announcement_content_sanitized", ""))
            for hit in query_hits
            if hit.get("announcement_content_sanitized", "")
        ]
        verified_contexts = [evidence["evidence_text"] for evidence in evidence_matches if evidence.get("evidence_text")]
        all_contexts = query_contexts + verified_contexts
        context_joined = "\n---CONTEXT---\n".join(all_contexts)
        context_sha256 = hashlib.sha256(context_joined.encode("utf-8")).hexdigest() if context_joined else ""
        annual_context_pass = bool(query_contexts or verified_contexts)
        context_screen_pass = annual_context_pass
        if verified_contexts:
            context_result = "VERIFIED_PAGE_EVIDENCE_CONTEXT"
        elif any(token in context_joined for token in negative_context_tokens):
            context_result = "EXPLICIT_NEGATIVE_OR_NON_DIRECT_CONTEXT"
        elif any(token in context_joined for token in direct_context_tokens):
            context_result = "POTENTIAL_DIRECT_BUSINESS_CONTEXT_REQUIRES_PAGE_VERIFICATION"
        elif query_contexts:
            context_result = "FULLTEXT_CONTEXT_INSUFFICIENT_FOR_DIRECT_BUSINESS"
        else:
            context_result = "NO_ANNUAL_REPORT_CONTEXT_ACQUIRED"
        identity_pass = row["security_code"] in universe_by_code
        source_pass = bool(direct_source_id and direct_source_id in source_by_id)
        locator_pass = bool(
            row.get("direct_business_locator")
            and any(
                evidence.get("source_page")
                or evidence.get("locator_value")
                or evidence.get("source_sentence_index")
                for evidence in evidence_matches
            )
        )
        evidence_pass = bool(evidence_matches)
        grade_pass = row.get("evidence_grade") in {"S", "A"}
        specificity_pass = row.get("exposure_specificity") in allowed_specificity
        eligible = all([
            identity_pass, annual_context_pass, context_screen_pass,
            source_pass, locator_pass, evidence_pass, grade_pass, specificity_pass,
        ])
        failed = []
        for label, passed in [
            ("A_SHARE_IDENTITY", identity_pass),
            ("ANNUAL_REPORT_CONTEXT_ACQUIRED", annual_context_pass),
            ("BUSINESS_CONTEXT_SCREEN_PERFORMED", context_screen_pass),
            ("DIRECT_BUSINESS_SOURCE", source_pass),
            ("PAGE_OR_TEXT_LOCATOR", locator_pass),
            ("COMPANY_EVIDENCE_FACT", evidence_pass),
            ("SOURCE_GRADE_S_OR_A", grade_pass),
            ("EXPOSURE_SPECIFICITY", specificity_pass),
        ]:
            if not passed:
                failed.append(label)
        gate_by_pair[pair] = {
            "query_hits": query_hits,
            "query_contexts": query_contexts,
            "context_sha256": context_sha256,
            "context_result": context_result,
            "context_excerpt": context_joined[:5000],
            "annual_context_pass": annual_context_pass,
            "context_screen_pass": context_screen_pass,
            "source_replacement_count": sum(int(hit.get("source_replacement_character_count", 0)) for hit in query_hits),
            "identity_pass": identity_pass,
            "source_pass": source_pass,
            "locator_pass": locator_pass,
            "evidence_pass": evidence_pass,
            "grade_pass": grade_pass,
            "specificity_pass": specificity_pass,
            "eligible": eligible,
            "failed": failed,
            "evidence_matches": evidence_matches,
        }
        if eligible:
            row["candidate_state"] = "ELIGIBLE"
            row["selection_rank"] = ""
            row["tier"] = ""
            row["include_or_exclude_reason"] = (
                "REPAIR003统一年度报告语境核验通过:官方年报语境已取得并判定,且A股身份、直接业务主源、"
                "页级/文本定位、公司证据事实、S/A来源等级和暴露具体性均通过;进入全部ELIGIBLE机械排序。"
            )
        else:
            row["candidate_state"] = "HELD_BY_EVIDENCE_GAP"
            row["selection_rank"] = ""
            row["tier"] = ""
            row["tie_break_rule"] = "NOT_ELIGIBLE_REPAIR003_ANNUAL_REPORT_CONTEXT_AND_PAGE_FUNNEL"
            row["include_or_exclude_reason"] = (
                f"REPAIR003已取得/处理官方年度报告全文命中语境,语境判定={context_result};"
                "资格 gate 未通过:" + ",".join(failed)
                + ";无稳定页级直接业务证据时保持HELD且不参与T1/T2排序。"
            )
    grade_order = {"S": 0, "A": 1}
    specificity_order = {
        "SEGMENT_REVENUE_OR_ASSET_DISCLOSED": 0,
        "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT": 1,
        "GENERAL_DIRECT_BUSINESS_DESCRIPTION": 2,
    }
    def descending_date(value: str) -> int:
        digits = re.sub(r"\D", "", value or "")[:8]
        return -int(digits or "0")
    eligible_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for row in candidates:
        if row["candidate_state"] == "ELIGIBLE":
            eligible_groups[(row["track_code"], row["selection_bucket"])].append(row)
    for group_rows in eligible_groups.values():
        group_rows.sort(key=lambda row: (
            grade_order.get(row["evidence_grade"], 9),
            specificity_order.get(row["exposure_specificity"], 9),
            descending_date(row["latest_disclosed_period"]),
            descending_date(source_by_id[row["direct_business_source_id"]].get("publish_date", "")),
            row["exchange_code"],
            row["security_code"],
        ))
        for rank, row in enumerate(group_rows, 1):
            row["selection_rank"] = str(rank)
            row["tie_break_rule"] = (
                "source_grade>exposure_specificity>latest_period>publish_date>exchange_code>security_code"
            )
            if rank == 1:
                row["candidate_state"] = "INCLUDED_T1"
                row["tier"] = "T1_PRIMARY"
            elif rank == 2:
                row["candidate_state"] = "INCLUDED_T2"
                row["tier"] = "T2_CANDIDATE"
            else:
                row["candidate_state"] = "ELIGIBLE_NOT_SELECTED_BATCH001"
                row["tier"] = ""
                row["include_or_exclude_reason"] += " 超过本批同桶两家上限,保留为ELIGIBLE_NOT_SELECTED_BATCH001。"
    state_order = {"INCLUDED_T1": 0, "INCLUDED_T2": 1, "ELIGIBLE_NOT_SELECTED_BATCH001": 2, "HELD_BY_EVIDENCE_GAP": 3}
    candidates.sort(key=lambda row: (
        TRACK_ORDER[row["track_code"]],
        BUCKET_ORDER[(row["track_code"], row["selection_bucket"])],
        state_order.get(row["candidate_state"], 9),
        int(row["selection_rank"] or 999999),
        row["exchange_code"],
        row["security_code"],
    ))
    funnel_headers = [
        "qualification_row_id", "task_id", "case_id", "batch_id", "run_id",
        "company_id", "security_code", "track_code", "selection_bucket",
        "query_hit_count", "query_source_ids", "announcement_ids", "annual_report_adjunct_urls",
        "annual_report_retrieval_attempt", "annual_report_retrieval_result",
        "fulltext_context_locator", "fulltext_context_count", "fulltext_context_sha256",
        "fulltext_context_excerpt_sanitized", "source_replacement_character_count",
        "business_context_rule_result", "page_level_verification_attempt",
        "page_level_verification_result", "a_share_identity_gate",
        "direct_business_source_id", "direct_source_gate", "locator_gate",
        "company_evidence_fact_gate", "source_grade_gate", "exposure_specificity_gate",
        "evidence_fact_ids", "failed_gates", "eligibility_result",
        "eligible_rank_in_bucket", "final_candidate_state", "mechanical_sort_key",
        "funnel_rule_version", "replay_status", "review_status",
    ]
    funnel_rows = []
    for seq, row in enumerate(candidates, 1):
        pair = (row["company_id"], row["track_code"])
        gate = gate_by_pair[pair]
        query_hits = gate["query_hits"]
        evidence_matches = gate["evidence_matches"]
        direct_src = source_by_id.get(row.get("direct_business_source_id", ""), {})
        mechanical_key = "|".join([
            row.get("evidence_grade", ""), row.get("exposure_specificity", ""),
            row.get("latest_disclosed_period", ""), direct_src.get("publish_date", ""),
            row.get("exchange_code", ""), row.get("security_code", ""),
        ])
        funnel_rows.append({
            "qualification_row_id": f"QUAL-{seq:05d}", "task_id": TASK_ID,
            "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "company_id": row["company_id"], "security_code": row["security_code"],
            "track_code": row["track_code"], "selection_bucket": row["selection_bucket"],
            "query_hit_count": str(len(query_hits)),
            "query_source_ids": ";".join(sorted({hit["source_doc_id"] for hit in query_hits})),
            "announcement_ids": ";".join(sorted({hit["announcement_id"] for hit in query_hits if hit["announcement_id"]})),
            "annual_report_adjunct_urls": ";".join(sorted({hit["adjunct_url"] for hit in query_hits if hit["adjunct_url"]})),
            "annual_report_retrieval_attempt": "CNINFO_OFFICIAL_2025_ANNUAL_REPORT_FULLTEXT_CONTEXT_FETCH",
            "annual_report_retrieval_result": (
                "ACQUIRED_FULLTEXT_QUERY_CONTEXT"
                if gate["query_contexts"]
                else "ACQUIRED_ARCHIVED_ANNUAL_REPORT_PAGE_CONTEXT"
                if evidence_matches
                else "FAILED_NO_ANNUAL_REPORT_CONTEXT"
            ),
            "fulltext_context_locator": ";".join(
                sorted({f"{hit['source_doc_id']}:{hit['announcement_id']}" for hit in query_hits})
            ),
            "fulltext_context_count": str(len(gate["query_contexts"])),
            "fulltext_context_sha256": gate["context_sha256"],
            "fulltext_context_excerpt_sanitized": gate["context_excerpt"],
            "source_replacement_character_count": str(gate["source_replacement_count"]),
            "business_context_rule_result": gate["context_result"],
            "page_level_verification_attempt": "CHECK_ARCHIVED_PDF_OR_STABLE_TEXT_LOCATOR_AFTER_FULLTEXT_CONTEXT",
            "page_level_verification_result": (
                "PASS_VERIFIED_DIRECT_BUSINESS_LOCATOR"
                if gate["locator_pass"]
                else "INSUFFICIENT_NO_STABLE_PAGE_LEVEL_DIRECT_BUSINESS_LOCATOR"
            ),
            "a_share_identity_gate": "PASS" if gate["identity_pass"] else "FAIL",
            "direct_business_source_id": row.get("direct_business_source_id", ""),
            "direct_source_gate": "PASS" if gate["source_pass"] else "FAIL",
            "locator_gate": "PASS" if gate["locator_pass"] else "FAIL",
            "company_evidence_fact_gate": "PASS" if gate["evidence_pass"] else "FAIL",
            "source_grade_gate": "PASS" if gate["grade_pass"] else "FAIL",
            "exposure_specificity_gate": "PASS" if gate["specificity_pass"] else "FAIL",
            "evidence_fact_ids": ";".join(sorted(evidence["evidence_fact_id"] for evidence in evidence_matches)),
            "failed_gates": ";".join(gate["failed"]),
            "eligibility_result": "ELIGIBLE" if gate["eligible"] else "HELD_BY_EVIDENCE_GAP",
            "eligible_rank_in_bucket": row["selection_rank"] if gate["eligible"] else "",
            "final_candidate_state": row["candidate_state"],
            "mechanical_sort_key": mechanical_key,
            "funnel_rule_version": "REPAIR003_ANNUAL_REPORT_CONTEXT_QUALIFICATION_V1",
            "replay_status": "ATTEMPT_COMPLETED_FOR_DISCOVERED_PAIR",
            "review_status": REVIEW_STATUS,
        })
    write_csv(EXTRACTED_ROOT / "candidate_qualification_funnel.csv", funnel_headers, funnel_rows)
    return candidates, funnel_rows
def make_source_documents(existing_rows: list[dict[str, str]], new_sources: list[dict[str, str]]) -> list[dict[str, str]]:
    headers = list(existing_rows[0].keys())
    rows = []
    for old in existing_rows:
        row = dict(old)
        if row["doc_id"] in {"S-MARKET-NUCLEAR-CNEA-2024", "S-MARKET-NUCLEAR-CEC-2024"}:
            row["doc_id"] = "S-MARKET-NUCLEAR-CEC-2024"
            row["source_org"] = "中国电力企业联合会"
            row["author"] = "中国电力企业联合会"
            row["raw_file_path"] = row["raw_file_path"].replace("NUCLEAR_CNEA_2024_OPERATION.html", "NUCLEAR_CEC_2024_OPERATION.html")
            row["file_name"] = "NUCLEAR_CEC_2024_OPERATION.html"
            row["legal_access_note"] = "中国电力企业联合会公开页;页面明确标注来源;仅作民用核电公开历史对照。"
        rows.append(row)
    for src in new_sources:
        raw_path: Path = src["raw_path"]
        rows.append({
            "doc_id": src["doc_id"], "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_type": src["doc_type"],
            "title": src["title"], "source_org": src["source_org"], "author": src["author"],
            "publish_date": AS_OF_DATE, "collected_at": COLLECTED_AT, "source_url": src["source_url"],
            "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
            "subindustry_id": src["subindustry_id"], "company_id": "",
            "raw_pool_path": "ana-data/cases/新能源案例/raw/", "raw_file_path": rel(raw_path),
            "converted_text_path": "", "converted_markdown_path": "", "file_sha256": src["raw_hash"],
            "file_name": raw_path.name, "file_size": str(raw_path.stat().st_size),
            "detected_type": "XLSX" if raw_path.suffix.lower() == ".xlsx" else "JSON",
            "source_language": "zh-CN", "public_access_basis": "OFFICIAL_PUBLIC_QUERY_DIRECT",
            "access_status": "PUBLIC_DIRECT", "source_level": "S", "sensitivity_screen": src["sensitivity"],
            "legal_access_note": "官方公开查询回执;只用于A股身份或候选发现,不替代公司—赛道直接业务页级证据。",
            "doc_status": "INCLUDED_DISCOVERY_ONLY", "processing_status": "RAW_ARCHIVED_DISCOVERY_INDEXED",
        })
    # Preserve the existing schema order; all appended rows use those fields.
    for row in rows:
        for h in headers:
            row.setdefault(h, "")
    return rows
def convert_sources(source_rows: list[dict[str, str]], evidence_rows: list[dict[str, str]], discovery_summaries: list[dict]) -> list[dict[str, str]]:
    pages_by_doc: dict[str, set[int]] = defaultdict(set)
    for ev in evidence_rows:
        if ev.get("source_page", "").isdigit():
            pages_by_doc[ev["doc_id"]].add(int(ev["source_page"]))
    summary_by_source = {s["source_doc_id"]: s for s in discovery_summaries}
    evidence_docs = {ev["doc_id"] for ev in evidence_rows}
    conversions = []
    for row in source_rows:
        raw_path = PROJECT_ROOT / row["raw_file_path"]
        if not raw_path.exists():
            raise FileNotFoundError(raw_path)
        doc_id = row["doc_id"]
        detected = row["detected_type"].upper()
        if detected == "PDF":
            out = CONVERTED_ROOT / f"official_filings/{doc_id}__cited_pages.txt"
            reader = PdfReader(str(raw_path))
            page_numbers = sorted(pages_by_doc.get(doc_id) or {1})
            parts = [
                f"source_doc_id={doc_id}", f"raw_path={rel(raw_path)}", f"raw_sha256={sha256_file(raw_path)}",
                f"conversion_scope=CITED_PAGES_ONLY:{','.join(map(str, page_numbers))}",
                "note=正式证据只引用列出的页码;本转换不是全文覆盖声明。", "",
            ]
            for page_no in page_numbers:
                if not 1 <= page_no <= len(reader.pages):
                    raise RuntimeError(f"Page {page_no} out of range for {doc_id}")
                parts.extend([f"===== PDF_PAGE {page_no} =====", reader.pages[page_no - 1].extract_text() or "", ""])
            write_text(out, "\n".join(parts))
            method = "PYPDF_CITED_PAGE_TEXT_EXTRACTION"
            params = f"UTF-8; cited_pages={','.join(map(str, page_numbers))}; page markers retained"
            page_count = str(len(reader.pages))
        elif detected == "HTML":
            out = CONVERTED_ROOT / f"official_market/{doc_id}.txt"
            text = html_to_text(raw_path.read_bytes())
            write_text(out, f"source_doc_id={doc_id}\nraw_path={rel(raw_path)}\n\n{text}")
            method = "HTML_TEXT_NORMALIZATION"
            params = "Python HTMLParser; whitespace normalized; source URL retained in source_document"
            page_count = "N/A_HTML"
        elif detected == "JSON":
            out = CONVERTED_ROOT / f"official_discovery/{doc_id}.txt"
            if doc_id in summary_by_source:
                s = summary_by_source[doc_id]
                text = "\n".join(f"{k}={v}" for k, v in s.items()) + "\n"
            else:
                obj = json.loads(raw_path.read_text(encoding="utf-8"))
                payload_count = len(obj.get("payloads", []))
                text = f"source_doc_id={doc_id}\nas_of_date={AS_OF_DATE}\npayload_count={payload_count}\n"
            write_text(out, text)
            method = "JSON_RECEIPT_SUMMARY"
            params = "Full official JSON payload retained in raw; converted text is a deterministic index summary"
            page_count = "N/A_JSON"
        elif detected == "XLSX":
            out = CONVERTED_ROOT / f"official_discovery/{doc_id}.txt"
            # The official workbook has a stale A1:A1 dimension, so read-only
            # mode would silently expose only its header row.
            workbook = openpyxl.load_workbook(raw_path, read_only=False, data_only=True)
            worksheet = workbook.active
            codes = []
            for values in worksheet.iter_rows(values_only=True):
                value = str(values[0] or "").strip().zfill(6)
                if re.fullmatch(r"(?:00|30)\d{4}", value):
                    codes.append(value)
            write_text(
                out,
                f"source_doc_id={doc_id}\nraw_path={rel(raw_path)}\nrecord_count={len(codes)}\n"
                + "\n".join(codes)
                + "\n",
            )
            method = "XLSX_FIRST_COLUMN_CODE_EXTRACTION"
            params = "openpyxl normal mode due stale A1:A1 dimension; official workbook; first column security codes retained in source order"
            page_count = f"ROWS={len(codes)}"
        else:
            raise RuntimeError(f"Unsupported source type {detected} for {doc_id}")
        row["converted_text_path"] = rel(out)
        row["processing_status"] = "TEXT_CONVERTED_EVIDENCE_EXTRACTED" if doc_id in evidence_docs else "TEXT_CONVERTED_INDEXED_DISCOVERY_ONLY"
        conversions.append({
            "conversion_id": f"CONV-{doc_id}", "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": doc_id,
            "raw_pool_path": row["raw_pool_path"], "raw_file_path": row["raw_file_path"],
            "raw_file_sha256": row["file_sha256"], "detected_type": detected,
            "conversion_method": method, "parameters_summary": params,
            "converted_text_path": rel(out), "converted_markdown_path": "", "converted_path": rel(out),
            "converted_sha256": sha256_file(out), "page_or_duration_count": page_count,
            "status": "CONVERTED_EVIDENCE_EXTRACTED" if doc_id in evidence_docs else "CONVERTED_INDEXED_DISCOVERY_ONLY",
            "error_code": "", "error_summary": "", "created_at": COLLECTED_AT,
        })
    return conversions
def update_evidence_facts(rows: list[dict[str, str]], source_rows: list[dict[str, str]]) -> list[dict[str, str]]:
    source_by_id = {r["doc_id"]: r for r in source_rows}
    for row in rows:
        if row["doc_id"] in {"S-MARKET-NUCLEAR-CNEA-2024", "S-MARKET-NUCLEAR-CEC-2024"}:
            row["doc_id"] = "S-MARKET-NUCLEAR-CEC-2024"
            row["original_qualifier"] = "中国电力企业联合会公开运行情况;归档页明确标注来源"
        row["source_text_path"] = source_by_id[row["doc_id"]]["converted_text_path"]
        if row["doc_id"] in {
            "S-MARKET-BATTERY-MIIT-2025-0104",
            "S-MARKET-RENEWABLE-NEA-2025",
            "S-MARKET-WINDSOLAR-NEA-2025",
            "S-MARKET-NUCLEAR-CEC-2024",
        }:
            row["processing_status"] = "EVIDENCE_EXTRACTED_AND_LINKED_TO_OUTPUT"
    return rows
def expand_classification(current_rows: list[dict[str, str]], candidates: list[dict[str, str]], source_rows: list[dict[str, str]]) -> list[dict[str, str]]:
    headers = list(current_rows[0].keys())
    current_by_pair = {(r["company_id"], r["track_code"]): dict(r) for r in current_rows}
    source_by_id = {r["doc_id"]: r for r in source_rows}
    rows = []
    for cand in candidates:
        key = (cand["company_id"], cand["track_code"])
        if key in current_by_pair:
            rows.append(current_by_pair[key])
            continue
        src = source_by_id[cand["candidate_source_id"]]
        rows.append({
            "classification_id": f"CLASS-DISC-{cand['track_code']}-{cand['security_code']}",
            "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "subject_type": "COMPANY_QUERY_HIT", "subject_id": cand["company_id"],
            "industry_id": "IND-NEWENERGY", "subindustry_id": cand["track_code"],
            "company_id": cand["company_id"], "track_code": cand["track_code"], "chain_node_id": "",
            "scope_type": "", "classification_reason": cand["include_or_exclude_reason"],
            "source_doc_id": cand["candidate_source_id"], "evidence_fact_id": "",
            "raw_pool_path": src["raw_pool_path"], "raw_file_sha256": src["file_sha256"],
            "data_status": cand["candidate_state"], "review_status": REVIEW_STATUS,
        })
    for row in rows:
        for h in headers:
            row.setdefault(h, "")
    return rows
def build_input_manifests(source_rows: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
    header = [
        "input_item_id", "task_id", "case_id", "batch_id", "run_id", "source_doc_id",
        "include_decision", "exclude_reason", "raw_pool_path", "raw_file_path", "raw_file_sha256",
        "source_url", "source_level", "public_access_basis", "sensitivity_screen",
        "processing_status", "review_status",
    ]
    industry_rows = []
    case_rows = []
    for src in source_rows:
        discovery = src["doc_type"] in {"OFFICIAL_A_SHARE_UNIVERSE_SNAPSHOT", "OFFICIAL_DISCOVERY_QUERY_RECEIPT"}
        row = {
            "input_item_id": f"INPUT-{src['doc_id']}", "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": src["doc_id"],
            "include_decision": "INCLUDE_DISCOVERY_ONLY" if discovery else "INCLUDE",
            "exclude_reason": "", "raw_pool_path": src["raw_pool_path"],
            "raw_file_path": src["raw_file_path"], "raw_file_sha256": src["file_sha256"],
            "source_url": src["source_url"], "source_level": src["source_level"],
            "public_access_basis": src["public_access_basis"], "sensitivity_screen": src["sensitivity_screen"],
            "processing_status": src["processing_status"], "review_status": REVIEW_STATUS,
        }
        industry_rows.append(row)
        case_row = dict(row)
        case_row["input_item_id"] = f"CASE-INPUT-{src['doc_id']}"
        case_rows.append(case_row)
    write_csv(MANIFEST_ROOT / "input_manifest.csv", header, industry_rows)
    case_header = ["case_input_item_id"] + header[1:]
    converted_case = [{"case_input_item_id": r.pop("input_item_id"), **r} for r in case_rows]
    write_csv(CASE_MANIFEST / "case_input_manifest.csv", case_header, converted_case)
    return industry_rows, converted_case
def build_source_gap_audit(source_rows: list[dict[str, str]], evidence_rows: list[dict[str, str]]) -> list[dict[str, str]]:
    header = [
        "source_gap_audit_id", "task_id", "case_id", "batch_id", "run_id", "source_doc_id",
        "source_received_flag", "raw_archived_flag", "converted_flag", "indexed_flag",
        "evidence_linked_flag", "raw_pool_path", "raw_file_sha256", "gap_type", "impact",
        "status", "review_status",
    ]
    evidence_docs = {r["doc_id"] for r in evidence_rows}
    rows = []
    for src in source_rows:
        linked = src["doc_id"] in evidence_docs
        rows.append({
            "source_gap_audit_id": f"SGA-{src['doc_id']}", "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": src["doc_id"],
            "source_received_flag": "YES", "raw_archived_flag": "YES", "converted_flag": "YES",
            "indexed_flag": "YES", "evidence_linked_flag": "YES" if linked else "DISCOVERY_ONLY_NOT_FORMAL_EVIDENCE",
            "raw_pool_path": src["raw_pool_path"], "raw_file_sha256": src["file_sha256"],
            "gap_type": "NONE", "impact": "NONE",
            "status": "COMPLETE_EVIDENCE_LINKED" if linked else "COMPLETE_DISCOVERY_ONLY",
            "review_status": REVIEW_STATUS,
        })
    write_csv(MANIFEST_ROOT / "source_gap_audit.csv", header, rows)
    return rows
def update_outputs() -> None:
    replacements = {
        "中国核能行业协会历史对照": "中国电力企业联合会历史对照",
        "[中国核能行业协会:2024年全国核电运行情况]": "[中国电力企业联合会:2024年全国核电运行情况]",
        "../evidence/evidence_fact.csv": "../../evidence/evidence_fact_table.csv",
        "../evidence/company_track_candidate_ledger.csv": "../../extracted/company_track_candidate_ledger.csv",
        "../evidence/newenergy_company_exposure_matrix.csv": "../../extracted/newenergy_company_exposure_matrix.csv",
        "../evidence/newenergy_supply_demand_price_metric.csv": "../../extracted/newenergy_supply_demand_price_metric.csv",
        "../evidence/newenergy_technology_route_matrix.csv": "../../extracted/newenergy_technology_route_matrix.csv",
        "../evidence/newenergy_project_capacity_event.csv": "../../extracted/newenergy_project_capacity_event.csv",
        "../manifest/source_document.csv": "../../manifest/source_document.csv",
        "../../../../evidence/company_track_candidate_ledger.csv": "../../../../../extracted/company_track_candidate_ledger.csv",
        "../../../../evidence/newenergy_technology_route_matrix.csv": "../../../../../extracted/newenergy_technology_route_matrix.csv",
        "../../../../evidence/newenergy_supply_demand_price_metric.csv": "../../../../../extracted/newenergy_supply_demand_price_metric.csv",
    }
    for path in CASE_OUTPUTS.rglob("*.md"):
        text = path.read_text(encoding="utf-8")
        for old, new in replacements.items():
            text = text.replace(old, new)
        text = re.sub(
            r"(?:\.\./)+manifest/source_document\.csv",
            "../../manifest/source_document.csv",
            text,
        )
        write_text(path, text)
    result = RESULT_ROOT / "result_index.md"
    text = result.read_text(encoding="utf-8")
    text = re.sub(
        r"(?:\.\./)+cases/[^/]+/" + re.escape(CASE_ID) + r"/outputs/",
        f"../../../cases/{INDUSTRY_ROOT.name}/{CASE_ID}/outputs/",
        text,
    )
    write_text(result, text)
def expand_case_evidence_map(
    current_rows: list[dict[str, str]], candidates: list[dict[str, str]], metrics: list[dict[str, str]],
    evidence_rows: list[dict[str, str]],
) -> list[dict[str, str]]:
    headers = list(current_rows[0].keys())
    rows = []
    for row in current_rows:
        row = dict(row)
        if row["evidence_fact_id"] == "EVF-MKT-NUC-2024":
            row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国核能行业协会", "中国电力企业联合会")
            row["conclusion_text"] = row["conclusion_text"].replace("中国核能行业协会", "中国电力企业联合会")
        elif row["evidence_fact_id"] == "EVF-MKT-NUC-01":
            row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国电力企业联合会", "中国核能行业协会")
            row["conclusion_text"] = row["conclusion_text"].replace("中国电力企业联合会", "中国核能行业协会")
        rows.append(row)
    selected = [r for r in candidates if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
    ev_by_company = {r["company_id"]: r for r in evidence_rows if r["subject_type"] == "COMPANY"}
    track_dir = {"BATTERY": "01_锂电", "SOLAR": "02_光伏", "WIND": "03_风电", "NUCLEAR": "04_核电"}
    seq = 1
    existing = {(r["output_path"], r["evidence_fact_id"], r["conclusion_text"]) for r in rows}
    def add(output_path: str, evidence_fact_id: str, conclusion_text: str, anchor: str, strength: str = "DIRECT_FACT", limit: str = "") -> None:
        nonlocal seq
        key = (output_path, evidence_fact_id, conclusion_text)
        if key in existing:
            return
        rows.append({
            "conclusion_evidence_map_id": f"CEM-REPAIR-{seq:04d}", "task_id": TASK_ID,
            "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "conclusion_id": f"CONC-REPAIR-{seq:04d}", "output_path": output_path,
            "section_anchor": anchor, "conclusion_text": conclusion_text,
            "conclusion_strength": strength, "evidence_fact_id": evidence_fact_id,
            "support_type": "SUPPORT", "contradiction_or_limit": limit,
            "review_status": REVIEW_STATUS,
        })
        existing.add(key)
        seq += 1
    first_by_track = {}
    for cand in selected:
        ev = ev_by_company[cand["company_id"]]
        related = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[cand['track_code']]}/相关企业.md")
        add(related, ev["evidence_fact_id"], ev["evidence_text"], f"company-{cand['security_code']}",
            limit="仅证明直接业务暴露;T1/T2只是本批研究深度,不是质量、估值或投资排序。")
        first_by_track.setdefault(cand["track_code"], (cand, ev))
    for track, (cand, ev) in first_by_track.items():
        industry_view = rel(CASE_OUTPUTS / "新能源行业视图.md")
        tech_doc = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[track]}/产业链与技术路线.md")
        add(industry_view, ev["evidence_fact_id"], f"{cand['security_name']}的官方年报直接支持其{track}业务映射。",
            f"track-{track.lower()}", limit="公司例证不构成行业或公司全集。")
        add(tech_doc, ev["evidence_fact_id"], f"{cand['security_name']}的公开产品/业务事实作为产业链节点例证。",
            "official-company-example", strength="MECHANISM_ONLY",
            limit="只支持公开产品/业务节点,不据此推导技术优劣、份额或投资结论。")
    for metric in metrics:
        track = metric["track_code"]
        if track == "CROSS_TRACK":
            industry_view = rel(CASE_OUTPUTS / "新能源行业视图.md")
            text = f"{metric['metric_name']}={metric['metric_value']}{metric['metric_unit']}({metric['metric_period']})"
            add(
                industry_view,
                metric["evidence_fact_id"],
                text,
                f"metric-{metric['metric_id'].lower()}",
                limit=metric["qualifier"],
            )
            continue
        market_doc = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[track]}/市场与供需.md")
        text = f"{metric['metric_name']}={metric['metric_value']}{metric['metric_unit']}({metric['metric_period']})"
        add(market_doc, metric["evidence_fact_id"], text, f"metric-{metric['metric_id'].lower()}",
            limit=metric["qualifier"])
    primary_market_ev = {"BATTERY": "EVF-MKT-BAT-01", "SOLAR": "EVF-MKT-SOL-01",
                         "WIND": "EVF-MKT-WIND-01", "NUCLEAR": "EVF-MKT-NUC-01"}
    ev_by_id = {r["evidence_fact_id"]: r for r in evidence_rows}
    for track, evidence_id in primary_market_ev.items():
        overview = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[track]}/总览.md")
        ev = ev_by_id[evidence_id]
        add(overview, evidence_id, ev["evidence_text"], "market-fact-summary",
            limit="按来源原始期间、地域和阶段使用;不得外推或跨口径拼接。")
    nuclear_2024 = ev_by_id["EVF-MKT-NUC-2024"]
    for name in ["市场与供需.md", "总览.md"]:
        path = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/04_核电/{name}")
        add(path, nuclear_2024["evidence_fact_id"], nuclear_2024["evidence_text"], "nuclear-2024-history",
            limit="来源为中国电力企业联合会;仅作2024历史对照,不与2025口径静默拼接。")
    return rows
def materialize_evidence_locators(case_map: list[dict[str, str]]) -> list[dict[str, str]]:
    """Make every map row resolve to an explicit anchor and verbatim statement."""
    begin = "<!-- BEGIN GENERATED EVIDENCE LOCATORS REPAIR004 -->"
    end = "<!-- END GENERATED EVIDENCE LOCATORS REPAIR004 -->"
    by_output: dict[str, list[dict[str, str]]] = defaultdict(list)
    for row in case_map:
        row["conclusion_text"] = re.sub(r"\s+", " ", row["conclusion_text"]).strip()
        if row["evidence_fact_id"] == "EVF-MKT-NUC-2024":
            row["conclusion_text"] = row["conclusion_text"].replace("中国核能行业协会", "中国电力企业联合会")
            row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国核能行业协会", "中国电力企业联合会")
        elif row["evidence_fact_id"] == "EVF-MKT-NUC-01":
            row["conclusion_text"] = row["conclusion_text"].replace("中国电力企业联合会", "中国核能行业协会")
            row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国电力企业联合会", "中国核能行业协会")
        by_output[row["output_path"]].append(row)
    global_seq = 1
    for output_path in sorted(by_output):
        path = PROJECT_ROOT / output_path
        if not path.exists():
            raise FileNotFoundError(path)
        text = path.read_text(encoding="utf-8")
        text = re.sub(
            r"\n## 证据定位索引(REPAIR\d+)\n\n?"
            r"<!-- BEGIN GENERATED EVIDENCE LOCATORS REPAIR\d+ -->.*?"
            r"<!-- END GENERATED EVIDENCE LOCATORS REPAIR\d+ -->\n?",
            "\n",
            text,
            flags=re.S,
        ).rstrip()
        lines = ["", "", "## 证据定位索引(REPAIR004)", "", begin, ""]
        for row in sorted(
            by_output[output_path],
            key=lambda item: (item["evidence_fact_id"], item["conclusion_evidence_map_id"], item["conclusion_text"]),
        ):
            anchor = f"evidence-locator-{global_seq:04d}"
            global_seq += 1
            row["section_anchor"] = anchor
            row["review_status"] = REVIEW_STATUS
            lines.extend([
                f'<a id="{anchor}"></a>',
                f'- `{row["evidence_fact_id"]}`:{row["conclusion_text"]}',
                "",
            ])
        lines.append(end)
        write_text(path, text + "\n".join(lines) + "\n")
    return case_map
def rewrite_batch_summary(candidate_rows: list[dict[str, str]], source_count: int, conversion_count: int, universe_rows: list[dict[str, str]]) -> None:
    counts = defaultdict(int)
    for row in candidate_rows:
        counts[row["candidate_state"]] += 1
    exchange_counts = defaultdict(int)
    for row in universe_rows:
        exchange_counts[row["exchange_code"]] += 1
    text = f"""# {CASE_ID} / {BATCH_ID} 批次摘要
- `task_id={TASK_ID}`
- `run_id={RUN_ID}`
- `schema_version={SCHEMA_VERSION}`
- `primary_region=MAINLAND_CHINA`
- `global_comparator=SEPARATE_CONTEXT_ONLY`
- `source_cutoff_at={SOURCE_CUTOFF}`
- `track_count=4`
- `official_a_share_universe_count={len(universe_rows)}`(SSE={exchange_counts['SSE']},SZSE={exchange_counts['SZSE']},BSE={exchange_counts['BSE']})
- `candidate_discovery_pair_count={len(candidate_rows)}`
- `candidate_state_count=INCLUDED_T1:{counts['INCLUDED_T1']},INCLUDED_T2:{counts['INCLUDED_T2']},HELD_BY_EVIDENCE_GAP:{counts['HELD_BY_EVIDENCE_GAP']}`
- `selected_company_track_mapping_count={counts['INCLUDED_T1'] + counts['INCLUDED_T2']}`
- `tier_count=T1_PRIMARY:{counts['INCLUDED_T1']},T2_CANDIDATE:{counts['INCLUDED_T2']}`
- `source_document_count={source_count}`(原38份研究来源 + 3份交易所A股基准 + 16份候选发现回执)
- `conversion_status_count={conversion_count}`
- `valuation_market_interface=NOT_APPLICABLE_BATCH001`
- `market_reverse_gap_scan=NOT_APPLICABLE_BATCH001`
- `coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY`
- `research_mode=darkline_广撒网收集`
- `output_status=DRAFT_FOR_REVIEW`
- `review_status=PENDING_REPAIR005_FOCUSED_REREVIEW`
本批仍只形成中国大陆四赛道基础行业图谱与 A 股直接业务导航。候选发现先以三家交易所官方 A 股全集为身份基准,再以巨潮资讯 16 组固定全文检索生成完整命中账本;只有已有法定年报页级直接业务证据的对象进入 ELIGIBLE 排序,其余命中全部保持 `HELD_BY_EVIDENCE_GAP`。T1/T2 只表示本批研究深度。未进行估值、行情、交易、收益、完整覆盖或公司质量排名。核电仅使用民用公开高层信息并执行关键基础设施敏感信息停止边界。
"""
    write_text(CASE_MANIFEST / "batch_summary.md", text)
def write_discovery_receipt(summaries: list[dict], candidates: list[dict[str, str]], universe_rows: list[dict[str, str]]) -> None:
    state_counts = defaultdict(int)
    track_counts = defaultdict(int)
    for row in candidates:
        state_counts[row["candidate_state"]] += 1
        track_counts[row["track_code"]] += 1
    lines = [
        "# BATCH-001 候选发现与机械选择回执", "",
        f"- `task_id={TASK_ID}`", f"- `case_id={CASE_ID}`", f"- `batch_id={BATCH_ID}`", f"- `run_id={RUN_ID}`",
        f"- `as_of_date={AS_OF_DATE}`", f"- `source_cutoff_at={SOURCE_CUTOFF}`", "- `coverage_claim=NONE`", "",
        "## 1. A股身份基准", "",
        f"官方交易所基准合计 `{len(universe_rows)}` 条;唯一键为 `(exchange_code, security_code)`。三份完整官方响应保存于行业统一 raw,规范化全集保存于 `extracted/a_share_universe.csv`。该基准只证明证券身份,不证明新能源业务。", "",
        "## 2. 发现查询", "",
        "查询时间窗固定为 `2026-03-01..2026-05-15`,只查 `szb,cyb,hzb,kcb,bjs`,全文检索、发布日期升序、每页100条;从 pageNum=1 开始,持续取得直到 archived_record_count 精确等于 server_totalRecordNum。接口 totalpages 表示最后一个完整页号,存在残页时继续抓取 totalpages+1。只保留标题为2025年年度报告全文且能回连官方A股基准的命中。搜索引擎未作为纳入证据。", "",
        "| 赛道 | 桶 | 关键词 | 服务器记录 | 已归档记录 | API页值 | 实际页数 | 终止页条数 | A股年报唯一命中 | raw SHA-256 |", "|---|---|---|---:|---:|---:|---:|---:|---:|---|",
    ]
    for s in summaries:
        lines.append(f"| `{s['track_code']}` | {s['selection_bucket']} | {s['keyword']} | {s['server_total_record_num']} | {s['archived_record_count']} | {s['server_total_pages']} | {s['archived_page_count']} | {s['terminal_page_record_count']} | {s['annual_report_a_share_unique_pair_hits']} | `{s['raw_sha256']}` |")
    lines.extend([
        "", "## 3. 全量账本与选择", "",
        f"规范化后唯一 `(company_id, track_code)` 共 `{len(candidates)}` 条:BATTERY={track_counts['BATTERY']}、SOLAR={track_counts['SOLAR']}、WIND={track_counts['WIND']}、NUCLEAR={track_counts['NUCLEAR']}。",
        f"其中 `INCLUDED_T1={state_counts['INCLUDED_T1']}`、`INCLUDED_T2={state_counts['INCLUDED_T2']}`、`HELD_BY_EVIDENCE_GAP={state_counts['HELD_BY_EVIDENCE_GAP']}`。所有命中均保留,未入选/证据不足对象没有删除。", "",
        "`extracted/candidate_qualification_funnel.csv` 对完整唯一命中池逐行保存实际年度报告语境处理回执:公告 ID/附件 URL → 官方全文命中语境 → 语境 locator/hash/规则判定 → 稳定页级直接业务定位 → 公司 evidence fact → S/A来源等级 → 暴露具体性。每个发现 pair 均记录取得、处理、失败/不足结果,不再只做既有 evidence join。机械顺序仍为:直接业务来源等级 → 暴露具体性 → 披露期间 → 发布日期 → 交易所 → 证券代码。只有所有 gate 通过的对象进入 ELIGIBLE 排序;全文命中本身不升级为业务证据。", "",
        "## 4. 边界", "",
        "该回执只证明本次冻结查询的命中集合和选择过程可重跑,不声称覆盖全部新能源公司。核电查询只用于民用公开高层业务发现,不读取、保存或推断关键基础设施敏感细节。", "",
    ])
    write_text(SUPPLEMENT_ROOT / "candidate_discovery_receipt.md", "\n".join(lines))
    write_csv(MANIFEST_ROOT / "candidate_discovery_query_summary.csv", list(summaries[0].keys()), summaries)
def build_human_receipt(case_map: list[dict[str, str]], source_count: int) -> None:
    header = HEADERS["human_receipt"]
    by_path = defaultdict(list)
    for row in case_map:
        by_path[row["output_path"]].append(row)
    md_paths = sorted(CASE_OUTPUTS.rglob("*.md"))
    rows = []
    for i, path in enumerate(md_paths, 1):
        p = rel(path)
        name = path.name
        if name == "新能源行业视图.md":
            view_type = "INDUSTRY_VIEW"
        elif name == "新能源市场视图.md":
            view_type = "MARKET_VIEW"
        elif name == "新能源公司视图.md":
            view_type = "COMPANY_VIEW"
        elif name == "新能源报告索引.md":
            view_type = "REPORT_INDEX"
        elif name in {"summary.md", "readout.md"}:
            view_type = name.removesuffix(".md").upper()
        else:
            view_type = "SUBINDUSTRY_OR_GAP_DOC"
        mappings = by_path.get(p, [])
        rows.append({
            "validation_item_id": f"HDV-NEWENERGY-{i:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "view_type": view_type, "output_path": p,
            "document_status": "DRAFT_FOR_REPAIR005_FOCUSED_REREVIEW", "not_applicable_reason": "",
            "evidence_boundary": "OFFICIAL_PUBLIC_SOURCES; STRONG_FACTS_MAPPED; GAP_AND_MECHANISM_LIMITS_RETAINED",
            "source_count": str(source_count), "evidence_count": str(len({x['evidence_fact_id'] for x in mappings})),
            "conclusion_count": str(len(mappings)), "unknown_count": "0",
            "review_status": "PENDING_REPAIR005_FOCUSED_REREVIEW",
            "validated_by": "case_analysis.analyst.new_energy", "validated_at": COLLECTED_AT,
        })
    write_csv(CASE_MANIFEST / "human_doc_validation_receipt.csv", header, rows)
def rebuild_output_manifest() -> list[dict[str, str]]:
    path = CASE_MANIFEST / "output_manifest.csv"
    rows = read_csv(path)
    for row in rows:
        output = PROJECT_ROOT / row["output_path"]
        if not output.exists():
            raise FileNotFoundError(output)
        row["output_sha256"] = sha256_file(output)
        row["data_status"] = REVIEW_STATUS
        row["review_status"] = "PENDING_REPAIR005_FOCUSED_REREVIEW"
    write_csv(path, list(rows[0].keys()), rows)
    return rows
def write_exact_set_receipt_placeholder() -> Path:
    path = CASE_MANIFEST / "package_exact_set_receipt.md"
    write_text(path, "# Formal package exact-set receipt\n\nPENDING_ENUMERATION\n")
    return path
def formal_files(extra_tool: Path) -> list[Path]:
    roots = [
        RAW_ROOT, CONVERTED_ROOT, EXTRACTED_ROOT, SUPPLEMENT_ROOT, EVIDENCE_ROOT, MANIFEST_ROOT,
        CASE_ROOT, RESULT_ROOT,
    ]
    files = set()
    artifact_manifest = MANIFEST_ROOT / "artifact_manifest.csv"
    for root in roots:
        if not root.exists():
            continue
        for path in root.rglob("*"):
            if not path.is_file() or path.name == ".gitkeep" or path.resolve() == artifact_manifest.resolve():
                continue
            if any(part.lower() in {"tmp", "img"} for part in path.parts):
                continue
            files.add(path.resolve())
    files.add(extra_tool.resolve())
    return sorted(files, key=lambda p: rel(p))
def write_exact_set_receipt(files: list[Path]) -> None:
    counts = defaultdict(int)
    for path in files:
        r = rel(path)
        if "/raw/" in f"/{r}/":
            counts["raw"] += 1
        elif "/converted/" in f"/{r}/":
            counts["converted"] += 1
        elif "/extracted/" in f"/{r}/":
            counts["extracted"] += 1
        elif "/evidence/" in f"/{r}/":
            counts["evidence"] += 1
        elif "/manifest/" in f"/{r}/":
            counts["manifest"] += 1
        elif "/outputs/" in f"/{r}/":
            counts["outputs"] += 1
        elif "/result/" in f"/{r}/":
            counts["result"] += 1
        else:
            counts["other"] += 1
    text = f"""# BATCH-001 正式产物 exact-set 回执
- `task_id={TASK_ID}`
- `case_id={CASE_ID}`
- `batch_id={BATCH_ID}`
- `run_id={RUN_ID}`
- `generated_at={COLLECTED_AT}`
- `formal_file_count_excluding_artifact_manifest={len(files)}`
- `raw={counts['raw']}`
- `converted={counts['converted']}`
- `extracted={counts['extracted']}`
- `evidence={counts['evidence']}`
- `manifest={counts['manifest']}`
- `outputs={counts['outputs']}`
- `result={counts['result']}`
- `other_replay_tool={counts['other']}`
`artifact_manifest.csv` 对以上 exact-set 中每个文件逐项记录 bytes 与 SHA-256。该 manifest 自身因自引用哈希不可满足而明确自排除;复审时直接对 manifest 文件另算 SHA-256。`.gitkeep` 仅为目录脚手架,不属于正式产物并明确排除。`tmp/` 与 `img/` 本批没有被正式结论引用,均不进入 formal exact-set。
"""
    write_text(CASE_MANIFEST / "package_exact_set_receipt.md", text)
def artifact_type(path: Path) -> str:
    r = rel(path)
    if "/raw/" in f"/{r}/":
        return "RAW_DOCUMENT"
    if "/converted/" in f"/{r}/":
        return "CONVERTED_TEXT"
    if "/supplement/" in f"/{r}/":
        return "SUPPLEMENT_SOURCE"
    if "/extracted/" in f"/{r}/":
        return "EXTRACTED_FACT"
    if "/evidence/" in f"/{r}/":
        return "EVIDENCE_INDEX"
    if "/outputs/" in f"/{r}/":
        return "REPORT_MARKDOWN"
    if "/manifest/" in f"/{r}/":
        return "MANIFEST"
    if "/result/" in f"/{r}/":
        return "PACKAGE"
    if r.endswith(".py"):
        return "REPLAY_TOOL"
    return "OUTPUT_TABLE"
def build_artifact_manifest(source_rows: list[dict[str, str]], output_rows: list[dict[str, str]], tool_path: Path) -> list[dict[str, str]]:
    write_exact_set_receipt_placeholder()
    files = formal_files(tool_path)
    write_exact_set_receipt(files)
    files = formal_files(tool_path)
    source_by_raw = {r["raw_file_path"]: r for r in source_rows}
    source_by_converted = {r["converted_text_path"]: r for r in source_rows if r["converted_text_path"]}
    output_by_path = {r["output_path"]: r for r in output_rows}
    rows = []
    generic_i = 1
    for path in files:
        r = rel(path)
        src = source_by_raw.get(r) or source_by_converted.get(r)
        out = output_by_path.get(r)
        if out:
            artifact_id = out["artifact_id"]
        elif src and r == src["raw_file_path"]:
            artifact_id = f"ART-{src['doc_id']}-RAW"
        elif src:
            artifact_id = f"ART-{src['doc_id']}-CONVERTED"
        else:
            artifact_id = f"ART-REPAIR-{generic_i:04d}"
            generic_i += 1
        rows.append({
            "artifact_id": artifact_id, "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "artifact_type": artifact_type(path),
            "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
            "subindustry_id": src["subindustry_id"] if src else "",
            "company_id": src["company_id"] if src else "", "logical_path": r, "relative_path": r,
            "absolute_path": path.resolve().as_posix(), "file_name": path.name,
            "file_ext": path.suffix.lower(), "file_size": str(path.stat().st_size), "sha256": sha256_file(path),
            "source_doc_id": src["doc_id"] if src else "", "source_url": src["source_url"] if src else "",
            "source_collected_at": src["collected_at"] if src else COLLECTED_AT,
            "raw_pool_path": src["raw_pool_path"] if src else "", "source_file_name": path.name,
            "detected_type": path.suffix.lower().lstrip(".").upper() or "NO_EXT", "archive_file_name": path.name,
            "extension_added_by_archive_flag": "NO", "extension_mismatch_flag": "NO",
            "created_at": COLLECTED_AT, "created_by": "case_analysis.analyst.new_energy",
            "tool_or_method": "NEWENERGY_BATCH001_REPAIR_REBUILD",
            "tool_version": ARTIFACT_TOOL_VERSION, "parameters_summary": ARTIFACT_PARAMETERS_SUMMARY,
            "source_snapshot_id": f"SNAP-{src['doc_id']}" if src else "",
            "artifact_status": ARTIFACT_STATUS,
            "sensitivity_screen": src["sensitivity_screen"] if src else "LEGAL_PUBLIC_SCREENED",
        })
    if len({r["artifact_id"] for r in rows}) != len(rows):
        raise RuntimeError("Duplicate artifact_id")
    write_csv(MANIFEST_ROOT / "artifact_manifest.csv", HEADERS["artifact_manifest"], rows)
    return rows
def remove_case_level_duplicates() -> None:
    for name in [
        "company_track_candidate_ledger.csv", "classification_summary.csv", "evidence_fact.csv",
        "newenergy_scope_matrix.csv", "newenergy_technology_route_matrix.csv",
        "newenergy_supply_demand_price_metric.csv", "newenergy_project_capacity_event.csv",
        "newenergy_company_exposure_matrix.csv", "newenergy_catalyst_risk_register.csv",
    ]:
        path = CASE_EVIDENCE / name
        if path.exists():
            path.unlink()
    for name in ["artifact_manifest.csv", "source_document.csv", "input_manifest.csv", "source_gap_audit.csv"]:
        path = CASE_MANIFEST / name
        if path.exists():
            path.unlink()
def validate(
    candidates: list[dict[str, str]], universe_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
    conversions: list[dict[str, str]], artifacts: list[dict[str, str]], case_map: list[dict[str, str]],
    evidence_rows: list[dict[str, str]], query_summaries: list[dict], qualification_rows: list[dict[str, str]],
) -> None:
    errors = []
    if len(universe_rows) != len({(r["exchange_code"], r["security_code"]) for r in universe_rows}):
        errors.append("universe duplicate")
    exchange_counts: dict[str, int] = defaultdict(int)
    for row in universe_rows:
        exchange_counts[row["exchange_code"]] += 1
    expected_exchange_counts = {"SSE": 2310, "SZSE": 2898, "BSE": 333}
    if dict(exchange_counts) != expected_exchange_counts:
        errors.append(f"universe exchange counts {dict(exchange_counts)} != {expected_exchange_counts}")
    if len(candidates) != len({(r["company_id"], r["track_code"]) for r in candidates}):
        errors.append("candidate duplicate")
    if len(query_summaries) != 16:
        errors.append(f"query summary count {len(query_summaries)}")
    for summary in query_summaries:
        if summary["archived_record_count"] != summary["server_total_record_num"]:
            errors.append(f"query incomplete {summary['source_doc_id']}")
        expected_terminal = summary["server_total_record_num"] % 100 or 100
        if summary["terminal_page_record_count"] != expected_terminal:
            errors.append(f"query terminal page {summary['source_doc_id']}")
    if len(qualification_rows) != len(candidates):
        errors.append(f"qualification count {len(qualification_rows)} != {len(candidates)}")
    if len({(r["company_id"], r["track_code"]) for r in qualification_rows}) != len(qualification_rows):
        errors.append("qualification duplicate")
    for row in qualification_rows:
        if row["replay_status"] != "ATTEMPT_COMPLETED_FOR_DISCOVERED_PAIR":
            errors.append(f"qualification attempt missing {row['qualification_row_id']}")
        if row["annual_report_retrieval_result"] == "FAILED_NO_ANNUAL_REPORT_CONTEXT":
            errors.append(f"annual report context missing {row['qualification_row_id']}")
        if not row["fulltext_context_sha256"] or not row["business_context_rule_result"]:
            errors.append(f"annual report context unprocessed {row['qualification_row_id']}")
        if "\ufffd" in row["fulltext_context_excerpt_sanitized"]:
            errors.append(f"qualification replacement char {row['qualification_row_id']}")
        if row["eligibility_result"] == "ELIGIBLE" and row["page_level_verification_result"] != "PASS_VERIFIED_DIRECT_BUSINESS_LOCATOR":
            errors.append(f"eligible page locator {row['qualification_row_id']}")
        if row["eligibility_result"] != "ELIGIBLE" and not row["failed_gates"]:
            errors.append(f"held without failed gate {row['qualification_row_id']}")
    selected = [r for r in candidates if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
    if len(selected) != 32:
        errors.append(f"selected != 32 ({len(selected)})")
    if not any(r["candidate_state"] == "HELD_BY_EVIDENCE_GAP" for r in candidates):
        errors.append("no held discovery candidates")
    for track, _, bucket, _ in QUERY_CONFIG:
        bucket_rows = [r for r in selected if r["track_code"] == track and r["selection_bucket"] == bucket]
        if sorted((r["candidate_state"], r["selection_rank"]) for r in bucket_rows) != [("INCLUDED_T1", "1"), ("INCLUDED_T2", "2")]:
            errors.append(f"selection bucket invalid {track}/{bucket}")
    if len(source_rows) != len({r["doc_id"] for r in source_rows}):
        errors.append("source duplicate")
    if len(conversions) != len(source_rows):
        errors.append("conversion count mismatch")
    for row in conversions:
        path = PROJECT_ROOT / row["converted_path"]
        if not path.exists() or sha256_file(path) != row["converted_sha256"]:
            errors.append(f"conversion hash {row['conversion_id']}")
    evidence_doc_ids = {row["doc_id"] for row in evidence_rows}
    source_by_id = {row["doc_id"]: row for row in source_rows}
    conversion_by_id = {row["source_doc_id"]: row for row in conversions}
    for doc_id in evidence_doc_ids:
        if source_by_id[doc_id]["processing_status"] != "TEXT_CONVERTED_EVIDENCE_EXTRACTED":
            errors.append(f"source evidence processing {doc_id}")
        if conversion_by_id[doc_id]["status"] != "CONVERTED_EVIDENCE_EXTRACTED":
            errors.append(f"conversion evidence processing {doc_id}")
    formal = formal_files(Path(__file__))
    if len(artifacts) != len(formal):
        errors.append(f"artifact exact-set mismatch {len(artifacts)} != {len(formal)}")
    if any(row["tool_version"] != ARTIFACT_TOOL_VERSION for row in artifacts):
        errors.append("artifact tool version mismatch")
    artifact_paths = {r["relative_path"] for r in artifacts}
    missing = {rel(p) for p in formal} - artifact_paths
    if missing:
        errors.append(f"artifact missing {sorted(missing)[:5]}")
    required_output_fragments = ["新能源行业视图.md"] + [f"{d}/{n}" for d in ["01_锂电", "02_光伏", "03_风电", "04_核电"] for n in ["总览.md", "产业链与技术路线.md", "市场与供需.md", "相关企业.md"]]
    mapped_paths = {r["output_path"] for r in case_map}
    for fragment in required_output_fragments:
        if not any(p.endswith(fragment) for p in mapped_paths):
            errors.append(f"unmapped output {fragment}")
    if "EVF-MKT-NUC-2024" not in {r["evidence_fact_id"] for r in case_map}:
        errors.append("nuclear 2024 evidence unmapped")
    for row in case_map:
        output = PROJECT_ROOT / row["output_path"]
        if not output.exists():
            errors.append(f"map output missing {row['conclusion_evidence_map_id']}")
            continue
        text = output.read_text(encoding="utf-8")
        if f'<a id="{row["section_anchor"]}"></a>' not in text:
            errors.append(f"map anchor missing {row['conclusion_evidence_map_id']}")
        if row["conclusion_text"] not in text:
            errors.append(f"map conclusion missing {row['conclusion_evidence_map_id']}")
    cec_source = source_by_id.get("S-MARKET-NUCLEAR-CEC-2024", {})
    if cec_source.get("source_org") != "中国电力企业联合会" or cec_source.get("author") != "中国电力企业联合会":
        errors.append("CEC source attribution")
    nuclear_2024 = next((row for row in evidence_rows if row["evidence_fact_id"] == "EVF-MKT-NUC-2024"), None)
    if not nuclear_2024 or "中国电力企业联合会" not in nuclear_2024["original_qualifier"]:
        errors.append("CEC evidence attribution")
    nuclear_2025_maps = [row for row in case_map if row["evidence_fact_id"] == "EVF-MKT-NUC-01"]
    nuclear_2025_attributed_maps = [
        row for row in nuclear_2025_maps
        if "中国核能行业协会" in (row["conclusion_text"] + " " + row["contradiction_or_limit"])
        or "中国电力企业联合会" in (row["conclusion_text"] + " " + row["contradiction_or_limit"])
    ]
    if len(nuclear_2025_attributed_maps) != 4:
        errors.append(f"nuclear 2025 attributed map count {len(nuclear_2025_attributed_maps)}")
    for row in nuclear_2025_attributed_maps:
        scope_text = row["conclusion_text"] + " " + row["contradiction_or_limit"]
        if "中国核能行业协会" not in scope_text or "中国电力企业联合会" in scope_text:
            errors.append(f"nuclear 2025 attribution {row['conclusion_evidence_map_id']}")
    for row in [item for item in case_map if item["evidence_fact_id"] == "EVF-MKT-NUC-2024"]:
        scope_text = row["conclusion_text"] + " " + row["contradiction_or_limit"]
        if "中国电力企业联合会" not in scope_text or "中国核能行业协会" in scope_text:
            errors.append(f"nuclear 2024 map attribution {row['conclusion_evidence_map_id']}")
    for path in [INDUSTRY_ROOT / x for x in ["converted", "extracted", "supplement", "evidence", "manifest"]]:
        if not path.exists():
            errors.append(f"missing industry root {path}")
    if errors:
        raise RuntimeError("Validation failed:\n- " + "\n- ".join(errors))
def main() -> None:
    ensure_roots()
    # Reruns read the industry canonical produced by an earlier successful build;
    # the first run falls back to the HOLD-era case-local files.
    candidate_input = EXTRACTED_ROOT / "company_track_candidate_ledger.csv"
    if not candidate_input.exists():
        candidate_input = OLD_CASE_EVIDENCE / "company_track_candidate_ledger.csv"
    current_candidates = [
        row for row in read_csv(candidate_input)
        if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}
    ]
    selected_keys = {(row["company_id"], row["track_code"]) for row in current_candidates}
    classification_input = EXTRACTED_ROOT / "classification_summary.csv"
    if not classification_input.exists():
        classification_input = OLD_CASE_EVIDENCE / "classification_summary.csv"
    current_classification = [
        row for row in read_csv(classification_input)
        if (row["company_id"], row["track_code"]) in selected_keys
    ]
    evidence_input = EVIDENCE_ROOT / "evidence_fact_table.csv"
    if not evidence_input.exists():
        evidence_input = OLD_CASE_EVIDENCE / "evidence_fact.csv"
    evidence_rows = read_csv(evidence_input)
    current_case_map = read_csv(OLD_CASE_EVIDENCE / "case_evidence_map.csv")
    source_input = MANIFEST_ROOT / "source_document.csv"
    if not source_input.exists():
        source_input = OLD_CASE_MANIFEST / "source_document.csv"
    source_rows = [
        row for row in read_csv(source_input)
        if row["doc_type"] not in {"OFFICIAL_A_SHARE_UNIVERSE_SNAPSHOT", "OFFICIAL_DISCOVERY_QUERY_RECEIPT"}
    ]
    output_rows = read_csv(OLD_CASE_MANIFEST / "output_manifest.csv")
    old_cec = RAW_ROOT / "official_market/NUCLEAR_CNEA_2024_OPERATION.html"
    new_cec = RAW_ROOT / "official_market/NUCLEAR_CEC_2024_OPERATION.html"
    if old_cec.exists() and not new_cec.exists():
        old_cec.replace(new_cec)
    elif old_cec.exists() and new_cec.exists():
        if sha256_file(old_cec) != sha256_file(new_cec):
            raise RuntimeError("CEC raw rename collision")
        old_cec.unlink()
    universe_rows, universe_by_code, universe_sources = normalize_universe()
    hits, query_sources, query_summaries = collect_discovery_queries(universe_by_code)
    all_new_sources = universe_sources + query_sources
    source_rows = make_source_documents(source_rows, all_new_sources)
    source_by_id = {r["doc_id"]: r for r in source_rows}
    # Correct predecessor IDs before downstream joins.
    for row in evidence_rows:
        if row["doc_id"] == "S-MARKET-NUCLEAR-CNEA-2024":
            row["doc_id"] = "S-MARKET-NUCLEAR-CEC-2024"
    candidates = build_candidate_ledger(current_candidates, hits, universe_by_code, source_by_id)
    # Generate conversions, then backfill canonical source/evidence paths.
    conversions = convert_sources(source_rows, evidence_rows, query_summaries)
    evidence_rows = update_evidence_facts(evidence_rows, source_rows)
    source_by_id = {r["doc_id"]: r for r in source_rows}
    candidates, qualification_rows = apply_candidate_qualification_funnel(
        candidates, hits, universe_by_code, source_rows, evidence_rows
    )
    # Correct the CEC ID anywhere it remains in inherited case-level files.
    for root in [CASE_EVIDENCE, CASE_MANIFEST]:
        for path in root.glob("*.csv"):
            text = path.read_text(encoding="utf-8")
            text = text.replace("S-MARKET-NUCLEAR-CNEA-2024", "S-MARKET-NUCLEAR-CEC-2024")
            text = text.replace("NUCLEAR_CNEA_2024_OPERATION.html", "NUCLEAR_CEC_2024_OPERATION.html")
            write_text(path, text)
    classification = expand_classification(current_classification, candidates, source_rows)
    metric_input = EXTRACTED_ROOT / "newenergy_supply_demand_price_metric.csv"
    if not metric_input.exists():
        metric_input = OLD_CASE_EVIDENCE / "newenergy_supply_demand_price_metric.csv"
    metrics = read_csv(metric_input)
    for row in metrics:
        if row["source_id"] == "S-MARKET-NUCLEAR-CNEA-2024":
            row["source_id"] = "S-MARKET-NUCLEAR-CEC-2024"
    # Move common canonical tables to their industry-level authority.
    write_csv(MANIFEST_ROOT / "source_document.csv", list(source_rows[0].keys()), source_rows)
    write_csv(MANIFEST_ROOT / "conversion_status.csv", HEADERS["conversion_status"], conversions)
    build_input_manifests(source_rows)
    build_source_gap_audit(source_rows, evidence_rows)
    write_csv(EVIDENCE_ROOT / "evidence_fact_table.csv", list(evidence_rows[0].keys()), evidence_rows)
    write_csv(EXTRACTED_ROOT / "company_track_candidate_ledger.csv", list(candidates[0].keys()), candidates)
    write_csv(EXTRACTED_ROOT / "classification_summary.csv", list(classification[0].keys()), classification)
    for name in [
        "newenergy_scope_matrix.csv", "newenergy_technology_route_matrix.csv",
        "newenergy_project_capacity_event.csv", "newenergy_company_exposure_matrix.csv",
        "newenergy_catalyst_risk_register.csv",
    ]:
        input_path = EXTRACTED_ROOT / name
        if not input_path.exists():
            input_path = OLD_CASE_EVIDENCE / name
        rows = read_csv(input_path)
        write_csv(EXTRACTED_ROOT / name, list(rows[0].keys()), rows)
    write_csv(EXTRACTED_ROOT / "newenergy_supply_demand_price_metric.csv", list(metrics[0].keys()), metrics)
    update_outputs()
    case_map = expand_case_evidence_map(current_case_map, candidates, metrics, evidence_rows)
    case_map = materialize_evidence_locators(case_map)
    write_csv(CASE_EVIDENCE / "case_evidence_map.csv", list(case_map[0].keys()), case_map)
    write_discovery_receipt(query_summaries, candidates, universe_rows)
    rewrite_batch_summary(candidates, len(source_rows), len(conversions), universe_rows)
    build_human_receipt(case_map, len(source_rows))
    output_rows = rebuild_output_manifest()
    remove_case_level_duplicates()
    artifacts = build_artifact_manifest(source_rows, output_rows, Path(__file__))
    validate(
        candidates, universe_rows, source_rows, conversions, artifacts, case_map,
        evidence_rows, query_summaries, qualification_rows,
    )
    result = {
        "status": "PASS_LOCAL_REPAIR_BUILD",
        "universe": len(universe_rows), "candidate_pairs": len(candidates),
        "candidate_states": dict(sorted((s, sum(1 for r in candidates if r["candidate_state"] == s)) for s in {r["candidate_state"] for r in candidates})),
        "sources": len(source_rows), "conversions": len(conversions), "evidence_facts": len(evidence_rows),
        "case_evidence_map": len(case_map), "formal_artifacts_excluding_manifest_self": len(artifacts),
        "artifact_manifest_sha256": sha256_file(MANIFEST_ROOT / "artifact_manifest.csv"),
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
    main()
ana-data/tools/newenergy_batch001_repair004.py
New file
@@ -0,0 +1,1258 @@
#!/usr/bin/env python3
"""REPAIR004: acquire and page-verify the frozen potential-direct annual reports.
This tool intentionally consumes the already reviewed REPAIR003 discovery snapshot.
It does not issue new discovery queries and it does not expand the case scope.  Its
first phase acquires the 729 unique CNINFO adjunct PDFs referenced by the 858 frozen
potential-direct pairs.  Its second phase searches every readable page and writes
replayable attachment- and pair-level receipts.  Candidate reranking is performed
only after these receipts have been inspected.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import re
import shutil
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import pypdfium2 as pdfium
from pypdf import PdfReader
TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
BATCH_ID = "BATCH-001"
RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001-BATCH-001-001"
TOOL_VERSION = "REPAIR-004"
POTENTIAL = "POTENTIAL_DIRECT_BUSINESS_CONTEXT_REQUIRES_PAGE_VERIFICATION"
CNINFO_BASE = "https://static.cninfo.com.cn/"
USER_AGENT = "Mozilla/5.0 MB-X-NewEnergy-Research/REPAIR004 public-source-audit"
TRACK_KEYWORDS = {
    ("BATTERY", "资源与主材"): "正极材料",
    ("BATTERY", "电芯制造"): "锂离子电池",
    ("BATTERY", "系统/部件/BMS-Pack"): "电池管理系统",
    ("BATTERY", "设备与回收循环"): "锂电设备",
    ("SOLAR", "硅料/硅片与材料"): "光伏硅片",
    ("SOLAR", "电池片/组件"): "光伏组件",
    ("SOLAR", "设备/辅材/逆变器"): "光伏逆变器",
    ("SOLAR", "系统集成/电站建设运营"): "光伏电站",
    ("WIND", "材料与关键零部件"): "风电零部件",
    ("WIND", "整机"): "风力发电机组",
    ("WIND", "塔筒/海缆/工程配套"): "风电塔筒",
    ("WIND", "项目运营与运维服务"): "风电场",
    ("NUCLEAR", "运营商"): "核电运营",
    ("NUCLEAR", "工程/EPC"): "核电工程",
    ("NUCLEAR", "核岛/常规岛主设备"): "核电设备",
    ("NUCLEAR", "核级部件/材料/仪控电气"): "核级阀门",
}
CHAIN_NODES = {
    ("BATTERY", "资源与主材"): "锂资源;锂盐;正极材料及前驱体",
    ("BATTERY", "电芯制造"): "锂离子电池;电芯;动力/储能电池",
    ("BATTERY", "系统/部件/BMS-Pack"): "电池管理系统;BMS;模组/PACK",
    ("BATTERY", "设备与回收循环"): "锂电设备;动力电池回收",
    ("SOLAR", "硅料/硅片与材料"): "多晶硅;硅棒;光伏硅片",
    ("SOLAR", "电池片/组件"): "太阳能电池片;光伏组件",
    ("SOLAR", "设备/辅材/逆变器"): "光伏设备;辅材;光伏逆变器",
    ("SOLAR", "系统集成/电站建设运营"): "光伏系统集成;电站建设运营",
    ("WIND", "材料与关键零部件"): "风电材料;铸件;主轴;轴承;叶片",
    ("WIND", "整机"): "风电整机;风力发电机组",
    ("WIND", "塔筒/海缆/工程配套"): "风电塔筒;海缆;工程配套",
    ("WIND", "项目运营与运维服务"): "风电场;项目运营;运维服务",
    ("NUCLEAR", "运营商"): "民用核电运营",
    ("NUCLEAR", "工程/EPC"): "民用核电工程;EPC",
    ("NUCLEAR", "核岛/常规岛主设备"): "核岛主设备;常规岛主设备",
    ("NUCLEAR", "核级部件/材料/仪控电气"): "核级部件;材料;仪控电气",
}
DOWNLOAD_HEADERS = [
    "attachment_id", "adjunct_path", "source_url", "announcement_title",
    "security_codes", "security_names", "pair_count", "pair_ids",
    "attempted_at", "attempt_count", "http_status", "final_url",
    "content_type", "response_byte_count", "response_sha256", "raw_path",
    "raw_byte_count", "raw_sha256", "pdf_signature", "pdf_readable",
    "page_count", "acquisition_result", "failure_class", "failure_detail",
    "tool_version", "review_status",
]
PAIR_HEADERS = [
    "qualification_row_id", "task_id", "case_id", "batch_id", "run_id",
    "company_id", "security_code", "track_code", "selection_bucket",
    "search_terms", "attachment_ids", "attachment_urls", "attachment_count",
    "retrieval_success_count", "retrieval_failure_count", "searched_pdf_count",
    "searched_page_count", "keyword_hit_attachment_count", "keyword_hit_pages",
    "keyword_hit_count", "direct_context_hit_pages", "direct_context_hit_count",
    "exact_context_sentences", "negative_or_insufficient_context_samples",
    "page_search_result", "qualification_result", "failure_or_hold_reason",
    "receipt_sha256", "verified_at", "tool_version", "review_status",
]
def now_iso() -> str:
    return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()
def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()
def read_csv(path: Path) -> list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        return list(csv.DictReader(handle))
def write_csv(path: Path, headers: list[str], rows: Iterable[dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    with tmp.open("w", encoding="utf-8-sig", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=headers, extrasaction="ignore", quoting=csv.QUOTE_ALL)
        writer.writeheader()
        for row in rows:
            writer.writerow({key: row.get(key, "") for key in headers})
    os.replace(tmp, path)
def find_industry_root(project_root: Path) -> Path:
    matches = [p for p in (project_root / "ana-data" / "cases").rglob("candidate_qualification_funnel.csv")
               if CASE_ID not in str(p)]
    if len(matches) != 1:
        raise RuntimeError(f"expected one industry funnel, found {len(matches)}")
    return matches[0].parent.parent
def split_values(value: str) -> list[str]:
    return [item.strip() for item in value.split(";") if item.strip()]
def sanitize_detail(value: str, limit: int = 600) -> str:
    return re.sub(r"[\r\n\t]+", " ", value or "").strip()[:limit]
def load_announcement_metadata(industry_root: Path) -> dict[str, dict[str, str]]:
    result: dict[str, dict[str, str]] = {}
    for path in sorted((industry_root / "raw" / "official_discovery").glob("*.json")):
        payload = json.loads(path.read_text(encoding="utf-8-sig"))
        for page in payload.get("pages", []):
            for ann in page.get("response", {}).get("announcements", []):
                ann_id = str(ann.get("announcementId", ""))
                if not ann_id:
                    continue
                result.setdefault(ann_id, {
                    "attachment_id": ann_id,
                    "adjunct_path": str(ann.get("adjunctUrl", "")),
                    "announcement_title": re.sub(r"<[^>]+>", "", str(ann.get("announcementTitle", ""))),
                    "security_code": str(ann.get("secCode", "")),
                    "security_name": str(ann.get("secName", "")),
                })
    return result
def existing_pdf_for_attachment(industry_root: Path, attachment_id: str) -> Path | None:
    for path in (industry_root / "raw").rglob("*.pdf"):
        if attachment_id in path.name:
            return path
    return None
def validate_pdf(path: Path) -> tuple[str, str, int, str]:
    signature = ""
    readable = "NO"
    page_count = 0
    error = ""
    try:
        with path.open("rb") as handle:
            signature = handle.read(5).decode("latin-1", errors="replace")
        if signature != "%PDF-":
            return signature, readable, page_count, "MISSING_PDF_SIGNATURE"
        reader = PdfReader(str(path), strict=False)
        page_count = len(reader.pages)
        readable = "YES" if page_count > 0 else "NO"
        if page_count <= 0:
            error = "ZERO_PAGE_PDF"
    except Exception as exc:  # recorded in formal receipt
        error = sanitize_detail(f"{type(exc).__name__}: {exc}")
    return signature, readable, page_count, error
def download_one(item: dict[str, Any], raw_dir: Path, project_root: Path, retries: int = 3) -> dict[str, Any]:
    attachment_id = item["attachment_id"]
    target = raw_dir / f"{attachment_id}.pdf"
    attempted_at = now_iso()
    existing = None if item.get("force_http") else (target if target.exists() and target.stat().st_size > 0 else None)
    if existing is None and not item.get("force_http"):
        existing = existing_pdf_for_attachment(item["industry_root"], attachment_id)
    if existing is not None:
        if existing.resolve() != target.resolve():
            shutil.copy2(existing, target)
        signature, readable, pages, parse_error = validate_pdf(target)
        file_hash = sha256_file(target)
        size = target.stat().st_size
        return {
            **item, "attempted_at": attempted_at, "attempt_count": "0",
            "http_status": "REUSED_EXISTING_CANONICAL", "final_url": item["source_url"],
            "content_type": "application/pdf", "response_byte_count": str(size),
            "response_sha256": file_hash, "raw_path": target.relative_to(project_root).as_posix(),
            "raw_byte_count": str(size), "raw_sha256": file_hash,
            "pdf_signature": signature, "pdf_readable": readable, "page_count": str(pages),
            "acquisition_result": "REUSED_EXISTING_CANONICAL_PDF" if readable == "YES" else "REUSED_FILE_UNREADABLE",
            "failure_class": "" if readable == "YES" else "PDF_VALIDATION_FAILED",
            "failure_detail": parse_error, "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
        }
    last_class = ""
    last_detail = ""
    last_status = ""
    last_body = b""
    final_url = item["source_url"]
    content_type = ""
    for attempt in range(1, retries + 1):
        request = urllib.request.Request(item["source_url"], headers={"User-Agent": USER_AGENT})
        try:
            with urllib.request.urlopen(request, timeout=120) as response:
                last_status = str(response.getcode())
                final_url = response.geturl()
                content_type = response.headers.get("Content-Type", "")
                last_body = response.read()
            part = target.with_suffix(".pdf.part")
            part.write_bytes(last_body)
            os.replace(part, target)
            signature, readable, pages, parse_error = validate_pdf(target)
            file_hash = sha256_file(target)
            size = target.stat().st_size
            result = "ACQUIRED_PDF_AND_VALIDATED" if readable == "YES" else "ACQUIRED_RESPONSE_PDF_VALIDATION_FAILED"
            return {
                **item, "attempted_at": attempted_at, "attempt_count": str(attempt),
                "http_status": last_status, "final_url": final_url, "content_type": content_type,
                "response_byte_count": str(len(last_body)), "response_sha256": sha256_bytes(last_body),
                "raw_path": target.relative_to(project_root).as_posix(), "raw_byte_count": str(size),
                "raw_sha256": file_hash, "pdf_signature": signature, "pdf_readable": readable,
                "page_count": str(pages), "acquisition_result": result,
                "failure_class": "" if readable == "YES" else "PDF_VALIDATION_FAILED",
                "failure_detail": parse_error, "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
            }
        except urllib.error.HTTPError as exc:
            last_status = str(exc.code)
            final_url = exc.geturl()
            content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
            try:
                last_body = exc.read()
            except Exception:
                last_body = b""
            last_class = "HTTP_ERROR"
            last_detail = sanitize_detail(f"HTTPError {exc.code}: {exc.reason}")
        except Exception as exc:
            last_class = type(exc).__name__.upper()
            last_detail = sanitize_detail(f"{type(exc).__name__}: {exc}")
        if attempt < retries:
            time.sleep(min(8, 2 ** attempt))
    return {
        **item, "attempted_at": attempted_at, "attempt_count": str(retries),
        "http_status": last_status, "final_url": final_url, "content_type": content_type,
        "response_byte_count": str(len(last_body)), "response_sha256": sha256_bytes(last_body) if last_body else "",
        "raw_path": "", "raw_byte_count": "0", "raw_sha256": "", "pdf_signature": "",
        "pdf_readable": "NO", "page_count": "0", "acquisition_result": "ACQUISITION_FAILED_AFTER_RETRIES",
        "failure_class": last_class, "failure_detail": last_detail,
        "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
    }
def normalize_text(value: str) -> str:
    return re.sub(r"\s+", "", value or "")
def sentence_windows(text: str, keyword: str, limit: int = 4) -> list[str]:
    cleaned = re.sub(r"[\u0000-\u0008\u000b\u000c\u000e-\u001f]", "", text or "")
    cleaned = re.sub(r"[ \t]+", " ", cleaned)
    parts = re.split(r"(?<=[。!?;;])|\n+", cleaned)
    result: list[str] = []
    for index, part in enumerate(parts):
        if keyword not in normalize_text(part):
            continue
        joined = "".join(parts[max(0, index - 1): min(len(parts), index + 2)]).strip()
        joined = sanitize_detail(joined, 1000)
        if joined and joined not in result:
            result.append(joined)
        if len(result) >= limit:
            break
    if not result and keyword in normalize_text(cleaned):
        norm = normalize_text(cleaned)
        pos = norm.find(keyword)
        result.append(norm[max(0, pos - 240):pos + len(keyword) + 360])
    return result
def search_pdf_worker(payload: tuple[str, str, list[str]]) -> dict[str, Any]:
    attachment_id, raw_path, keywords = payload
    result: dict[str, Any] = {
        "attachment_id": attachment_id, "page_count": 0, "readable": "NO", "error": "", "keyword_hits": {},
    }
    try:
        doc = pdfium.PdfDocument(raw_path)
        result["page_count"] = len(doc)
        result["readable"] = "YES"
        hit_map: dict[str, list[dict[str, Any]]] = {keyword: [] for keyword in keywords}
        for index in range(len(doc)):
            page = doc[index]
            textpage = page.get_textpage()
            text = textpage.get_text_range()
            normalized = normalize_text(text)
            for keyword in keywords:
                if keyword in normalized:
                    hit_map[keyword].append({
                        "page": index + 1,
                        "sentences": sentence_windows(text, keyword),
                    })
            textpage.close()
            page.close()
        doc.close()
        result["keyword_hits"] = hit_map
    except Exception as exc:
        result["error"] = sanitize_detail(f"{type(exc).__name__}: {exc}")
    return result
DIRECT_PATTERNS = [
    re.compile(r"(?:本公司|本集团|公司|集团).{0,120}(?:主营业务|主要业务|核心业务|主要从事|业务包括|产品包括|主要产品).{0,180}"),
    re.compile(r"(?:本公司|本集团|公司|集团).{0,120}(?:生产|制造|销售|提供服务|运营|承建|总承包).{0,160}"),
    re.compile(r"(?:本公司|本集团|公司|集团).{0,120}(?:旗下|全资子公司|控股子公司).{0,100}(?:专业从事|主营|生产|制造|销售|运营|承建|总承包).{0,160}"),
    re.compile(r"(?:主营业务|主要业务|核心业务|主要产品).{0,100}"),
]
NEGATIVE_TOKENS = ("不涉及", "不从事", "未从事", "无相关业务", "尚未开展", "不具备")
CONTEXT_ONLY_TOKENS = ("行业发展", "市场规模", "竞争对手", "供应商", "客户从事", "参股基金", "投资标的", "政策鼓励")
BUCKET_ROLE_PATTERNS = {
    ("BATTERY", "资源与主材"): [
        r"(?:公司|本公司|本集团).{0,100}(?:锂产品|锂盐|碳酸锂|氢氧化锂|正极材料|前驱体).{0,100}(?:生产|制造|销售|主营|主要从事)",
        r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:锂产品|锂盐|碳酸锂|氢氧化锂|正极材料|前驱体)",
    ],
    ("BATTERY", "电芯制造"): [
        r"(?:公司|本公司|本集团).{0,80}(?:生产|制造|销售|主营|主要从事).{0,40}(?:锂离子电池(?!材料|添加剂)|电芯|动力电池(?!材料)|储能电池(?!材料))",
        r"(?:公司|本公司|本集团).{0,80}(?:锂离子电池(?!材料|添加剂)|电芯|动力电池(?!材料)|储能电池(?!材料)).{0,40}(?:生产|制造|销售|主营业务|主要业务)",
    ],
    ("BATTERY", "系统/部件/BMS-Pack"): [
        r"(?:公司|本公司|本集团).{0,100}(?:电池管理系统|BMS|电池模组|PACK).{0,100}(?:产品|生产|制造|销售|供货|主营)",
        r"(?:核心产品|主要产品|产品包括).{0,100}(?:电池管理系统|BMS|电池模组|PACK)",
    ],
    ("BATTERY", "设备与回收循环"): [
        r"(?:公司|本公司|本集团).{0,100}(?:锂电设备|锂电池设备|电池回收|动力电池回收).{0,100}(?:产品|生产|制造|销售|业务|主营)",
        r"(?:核心产品|主要产品|主营业务).{0,100}(?:锂电设备|锂电池设备|电池回收|动力电池回收)",
    ],
    ("SOLAR", "硅料/硅片与材料"): [
        r"(?:公司|本公司|本集团).{0,100}(?:光伏硅片|单晶硅片|硅棒|多晶硅).{0,100}(?:生产|制造|销售|主营|主要业务)",
        r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:光伏硅片|单晶硅片|硅棒|多晶硅)",
    ],
    ("SOLAR", "电池片/组件"): [
        r"(?:公司|本公司|本集团).{0,100}(?:光伏组件|太阳能电池片|光伏电池片).{0,100}(?:生产|制造|销售|主营|主要业务)",
        r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:光伏组件|太阳能电池片|光伏电池片)",
    ],
    ("SOLAR", "设备/辅材/逆变器"): [
        r"(?:公司|本公司|本集团).{0,100}(?:光伏逆变器|光伏设备|光伏辅材).{0,100}(?:产品|生产|制造|销售|主营|主要业务)",
        r"(?:核心产品|主要产品|产品包括).{0,100}(?:光伏逆变器|光伏设备|光伏辅材)",
    ],
    ("SOLAR", "系统集成/电站建设运营"): [
        r"(?:公司|本公司|本集团).{0,120}(?:光伏电站|光伏发电站|分布式光伏).{0,120}(?:建设|运营|持有|投资开发|发电收入|EPC|总承包)",
        r"(?:公司|本公司|本集团).{0,120}(?:建设|运营|持有|投资开发|EPC|总承包).{0,120}(?:光伏电站|光伏发电站|分布式光伏)",
    ],
    ("WIND", "材料与关键零部件"): [
        r"(?:公司|本公司|本集团).{0,100}(?:风电零部件|风电铸件|风电主轴|风电轴承|风电叶片).{0,100}(?:生产|制造|销售|主营|主要业务|供应)",
        r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:风电零部件|风电铸件|风电主轴|风电轴承|风电叶片)",
    ],
    ("WIND", "整机"): [
        r"(?:公司|本公司|本集团).{0,100}(?:风力发电机组|风电整机).{0,100}(?:研发生产|生产|制造|销售|主营|产品)",
        r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:风力发电机组|风电整机)",
    ],
    ("WIND", "塔筒/海缆/工程配套"): [
        r"(?:公司|本公司|本集团).{0,100}(?:风电塔筒|风电塔架|海缆|海底电缆).{0,100}(?:生产|制造|销售|主营|工程|服务)",
        r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|承建).{0,100}(?:风电塔筒|风电塔架|海缆|海底电缆)",
    ],
    ("WIND", "项目运营与运维服务"): [
        r"(?:公司|本公司|本集团).{0,120}(?:风电场|风力发电项目).{0,120}(?:建设|运营|持有|投资开发|发电收入|运维|EPC)",
        r"(?:公司|本公司|本集团).{0,120}(?:建设|运营|持有|投资开发|运维|EPC).{0,120}(?:风电场|风力发电项目)",
    ],
    ("NUCLEAR", "运营商"): [
        r"(?:公司|本公司|本集团).{0,120}(?:核电站|核电机组|核电项目).{0,120}(?:运营|运行|持有|投资开发|发电)",
        r"(?:公司|本公司|本集团).{0,120}(?:运营|运行|持有|投资开发).{0,120}(?:核电站|核电机组|核电项目)",
    ],
    ("NUCLEAR", "工程/EPC"): [
        r"(?:公司|本公司|本集团).{0,120}(?:核电工程|核工程).{0,120}(?:EPC|总承包|承建|施工|服务|主营)",
        r"(?:公司|本公司|本集团).{0,120}(?:EPC|总承包|承建|施工).{0,120}(?:核电工程|核工程)",
    ],
    ("NUCLEAR", "核岛/常规岛主设备"): [
        r"(?:公司|本公司|本集团).{0,120}(?:核电设备|核岛设备|常规岛设备).{0,120}(?:生产|制造|销售|产品|主营)",
        r"(?:公司|本公司|本集团).{0,120}(?:生产|制造|销售|主营).{0,120}(?:核电设备|核岛设备|常规岛设备)",
    ],
    ("NUCLEAR", "核级部件/材料/仪控电气"): [
        r"(?:公司|本公司|本集团).{0,120}(?:核级阀门|核电阀门|核级材料|核电仪控).{0,120}(?:生产|制造|销售|产品|主营)",
        r"(?:公司|本公司|本集团).{0,120}(?:生产|制造|销售|主营).{0,120}(?:核级阀门|核电阀门|核级材料|核电仪控)",
    ],
}
def is_direct_context(sentence: str, keyword: str, track_code: str, selection_bucket: str) -> bool:
    norm = normalize_text(sentence)
    if keyword not in norm or any(token in norm for token in NEGATIVE_TOKENS):
        return False
    if any(token in norm for token in ("参股", "联营企业", "投资标的")) and not any(
        token in norm for token in ("控股子公司", "全资子公司")
    ):
        return False
    if any(token in norm for token in ("权益法", "长期股权投资", "合资公司将", "涉诉项目", "解除双方签订", "解除合同")):
        return False
    if any(token in norm for token in ("需遵守", "披露要求", "任职经历", "历任", "个人简历")):
        return False
    if selection_bucket == "电芯制造" and any(token in norm for token in ("电解液", "隔膜", "锂离子电池材料", "正极材料", "负极材料")) and "电芯" not in norm:
        return False
    if selection_bucket == "电芯制造" and any(token in norm for token in ("钢结构", "厂房工程", "基地建设项目", "工程项目")):
        return False
    if selection_bucket == "整机" and any(token in norm for token in ("转化为电能", "生产运营模式", "风力发电收入")):
        return False
    if selection_bucket == "塔筒/海缆/工程配套" and any(token in norm for token in ("募集资金", "已结项", "2009年", "2011年")):
        return False
    if any(token in norm for token in CONTEXT_ONLY_TOKENS) and not any(token in norm for token in ("本公司", "公司主营", "主要从事")):
        return False
    pos = norm.find(keyword)
    window = norm[max(0, pos - 240):pos + len(keyword) + 240]
    if not any(pattern.search(window) for pattern in DIRECT_PATTERNS):
        return False
    role_patterns = BUCKET_ROLE_PATTERNS[(track_code, selection_bucket)]
    return any(re.search(pattern, window) for pattern in role_patterns)
def build_attachment_items(
    potential_rows: list[dict[str, str]], metadata: dict[str, dict[str, str]], industry_root: Path
) -> list[dict[str, Any]]:
    grouped: dict[str, dict[str, Any]] = {}
    for row in potential_rows:
        ann_ids = split_values(row["announcement_ids"])
        urls = split_values(row["annual_report_adjunct_urls"])
        for index, ann_id in enumerate(ann_ids):
            meta = metadata.get(ann_id, {})
            adjunct = urls[index] if index < len(urls) else meta.get("adjunct_path", "")
            if not adjunct:
                raise RuntimeError(f"missing adjunct path for {ann_id}/{row['qualification_row_id']}")
            item = grouped.setdefault(ann_id, {
                "attachment_id": ann_id, "adjunct_path": adjunct,
                "source_url": CNINFO_BASE + adjunct.lstrip("/"),
                "announcement_title": meta.get("announcement_title", ""),
                "security_codes_set": set(), "security_names_set": set(), "pair_ids_set": set(),
                "keywords_set": set(), "industry_root": industry_root,
            })
            item["security_codes_set"].add(row["security_code"])
            if meta.get("security_name"):
                item["security_names_set"].add(meta["security_name"])
            item["pair_ids_set"].add(row["qualification_row_id"])
            keyword = TRACK_KEYWORDS.get((row["track_code"], row["selection_bucket"]))
            if not keyword:
                raise RuntimeError(f"no frozen keyword for {row['track_code']}/{row['selection_bucket']}")
            item["keywords_set"].add(keyword)
    result = []
    for item in grouped.values():
        item["security_codes"] = ";".join(sorted(item.pop("security_codes_set")))
        item["security_names"] = ";".join(sorted(item.pop("security_names_set")))
        item["pair_ids"] = ";".join(sorted(item.pop("pair_ids_set")))
        item["pair_count"] = str(len(split_values(item["pair_ids"])))
        item["keywords"] = sorted(item.pop("keywords_set"))
        result.append(item)
    return sorted(result, key=lambda x: x["attachment_id"])
def run_acquisition(
    items: list[dict[str, Any]], raw_dir: Path, receipt_path: Path, project_root: Path, workers: int,
    force_http: bool = False,
) -> list[dict[str, Any]]:
    receipt_by_id: dict[str, dict[str, Any]] = {}
    if receipt_path.exists():
        receipt_by_id = {row["attachment_id"]: row for row in read_csv(receipt_path)}
    pending = []
    for item in items:
        item["force_http"] = force_http
        prior = receipt_by_id.get(item["attachment_id"])
        if not force_http and prior and prior.get("pdf_readable") == "YES" and prior.get("raw_path"):
            path = project_root / prior["raw_path"]
            if path.exists() and sha256_file(path) == prior.get("raw_sha256"):
                continue
        pending.append(item)
    print(f"ACQUIRE total={len(items)} reusable_receipts={len(items)-len(pending)} pending={len(pending)}", flush=True)
    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = {executor.submit(download_one, item, raw_dir, project_root): item for item in pending}
        completed = 0
        for future in as_completed(futures):
            row = future.result()
            receipt_by_id[row["attachment_id"]] = row
            completed += 1
            if completed % 20 == 0 or completed == len(pending):
                ordered = [receipt_by_id[x["attachment_id"]] for x in items if x["attachment_id"] in receipt_by_id]
                write_csv(receipt_path, DOWNLOAD_HEADERS, ordered)
                good = sum(x.get("pdf_readable") == "YES" for x in ordered)
                print(f"ACQUIRE progress={completed}/{len(pending)} receipts={len(ordered)} readable={good}", flush=True)
    ordered = [receipt_by_id[x["attachment_id"]] for x in items]
    write_csv(receipt_path, DOWNLOAD_HEADERS, ordered)
    return ordered
def write_attachment_search_extract(
    converted_dir: Path, item: dict[str, Any], receipt: dict[str, Any], result: dict[str, Any]
) -> None:
    path = converted_dir / f"{item['attachment_id']}__keyword_pages.txt"
    lines = [
        f"attachment_id={item['attachment_id']}",
        f"source_url={item['source_url']}",
        f"raw_path={receipt.get('raw_path','')}",
        f"raw_sha256={receipt.get('raw_sha256','')}",
        f"page_count={result.get('page_count',0)}",
        f"searched_terms={';'.join(item['keywords'])}",
        f"tool_version={TOOL_VERSION}",
        "scope=keyword-hit pages only; no full-report conversion; public annual report",
        "",
    ]
    if result.get("error"):
        lines.append(f"SEARCH_ERROR={result['error']}")
    hit_total = 0
    for keyword in item["keywords"]:
        hits = result.get("keyword_hits", {}).get(keyword, [])
        hit_total += len(hits)
        lines.append(f"## keyword={keyword}; hit_page_count={len(hits)}")
        for hit in hits:
            lines.append(f"[page={hit['page']}]")
            if hit["sentences"]:
                lines.extend(hit["sentences"])
            else:
                lines.append("KEYWORD_PRESENT_BUT_SENTENCE_WINDOW_EMPTY")
            lines.append("")
    if hit_total == 0 and not result.get("error"):
        lines.append("NEGATIVE_RESULT=ALL_READABLE_PAGES_SEARCHED_NO_FROZEN_KEYWORD_HIT")
    path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
def run_search(
    items: list[dict[str, Any]], download_rows: list[dict[str, Any]], converted_dir: Path,
    search_cache_path: Path, project_root: Path, workers: int,
) -> dict[str, dict[str, Any]]:
    cache: dict[str, dict[str, Any]] = {}
    if search_cache_path.exists():
        cache = json.loads(search_cache_path.read_text(encoding="utf-8"))
    download_by_id = {row["attachment_id"]: row for row in download_rows}
    payloads = []
    for item in items:
        row = download_by_id[item["attachment_id"]]
        if row.get("pdf_readable") != "YES" or not row.get("raw_path"):
            cache[item["attachment_id"]] = {
                "attachment_id": item["attachment_id"], "page_count": 0, "readable": "NO",
                "error": row.get("failure_detail") or row.get("acquisition_result"), "keyword_hits": {},
            }
            continue
        cached = cache.get(item["attachment_id"])
        if cached and cached.get("raw_sha256") == row.get("raw_sha256") and cached.get("tool_version") == TOOL_VERSION:
            continue
        payloads.append((item["attachment_id"], str(project_root / row["raw_path"]), item["keywords"]))
    print(f"SEARCH total={len(items)} cached={len(items)-len(payloads)} pending={len(payloads)} workers={workers}", flush=True)
    with ProcessPoolExecutor(max_workers=workers) as executor:
        futures = {executor.submit(search_pdf_worker, payload): payload[0] for payload in payloads}
        completed = 0
        for future in as_completed(futures):
            result = future.result()
            ann_id = result["attachment_id"]
            result["raw_sha256"] = download_by_id[ann_id].get("raw_sha256", "")
            result["tool_version"] = TOOL_VERSION
            cache[ann_id] = result
            completed += 1
            if completed % 20 == 0 or completed == len(payloads):
                search_cache_path.parent.mkdir(parents=True, exist_ok=True)
                search_cache_path.write_text(json.dumps(cache, ensure_ascii=False, sort_keys=True), encoding="utf-8")
                hit_docs = sum(any(v for v in x.get("keyword_hits", {}).values()) for x in cache.values())
                print(f"SEARCH progress={completed}/{len(payloads)} cached={len(cache)} hit_documents={hit_docs}", flush=True)
    for item in items:
        write_attachment_search_extract(converted_dir, item, download_by_id[item["attachment_id"]], cache[item["attachment_id"]])
    search_cache_path.parent.mkdir(parents=True, exist_ok=True)
    search_cache_path.write_text(json.dumps(cache, ensure_ascii=False, sort_keys=True), encoding="utf-8")
    return cache
def build_pair_receipts(
    potential_rows: list[dict[str, str]], download_rows: list[dict[str, Any]],
    search_results: dict[str, dict[str, Any]], verified_at: str,
) -> list[dict[str, Any]]:
    downloads = {row["attachment_id"]: row for row in download_rows}
    rows: list[dict[str, Any]] = []
    for source in potential_rows:
        ann_ids = split_values(source["announcement_ids"])
        urls = split_values(source["annual_report_adjunct_urls"])
        keyword = TRACK_KEYWORDS[(source["track_code"], source["selection_bucket"])]
        retrieval_success = 0
        retrieval_failure = 0
        searched_pdf_count = 0
        searched_pages = 0
        keyword_pages: list[str] = []
        direct_pages: list[str] = []
        exact_sentences: list[str] = []
        insufficient_samples: list[str] = []
        keyword_hit_count = 0
        direct_hit_count = 0
        hit_attachment_ids: set[str] = set()
        errors: list[str] = []
        for ann_id in ann_ids:
            download = downloads[ann_id]
            result = search_results[ann_id]
            if download.get("pdf_readable") == "YES":
                retrieval_success += 1
                searched_pdf_count += 1
                searched_pages += int(result.get("page_count", 0))
            else:
                retrieval_failure += 1
                errors.append(f"{ann_id}:{download.get('acquisition_result')}:{download.get('failure_class')}")
                continue
            hits = result.get("keyword_hits", {}).get(keyword, [])
            if hits:
                hit_attachment_ids.add(ann_id)
            for hit in hits:
                keyword_hit_count += 1
                keyword_pages.append(f"{ann_id}:p{hit['page']}")
                sentences = hit.get("sentences", [])
                page_direct = False
                for sentence in sentences:
                    if is_direct_context(sentence, keyword, source["track_code"], source["selection_bucket"]):
                        direct_hit_count += 1
                        page_direct = True
                        exact_sentences.append(f"{ann_id}:p{hit['page']}:{sentence}")
                    elif len(insufficient_samples) < 8:
                        insufficient_samples.append(f"{ann_id}:p{hit['page']}:{sentence}")
                if page_direct:
                    direct_pages.append(f"{ann_id}:p{hit['page']}")
        if direct_hit_count:
            search_result = "FROZEN_KEYWORD_FOUND_WITH_DIRECT_SELF_BUSINESS_PAGE_CONTEXT"
            qualification = "PAGE_LEVEL_DIRECT_BUSINESS_CONTEXT_CONFIRMED_PENDING_EVIDENCE_REGISTRATION"
            hold_reason = ""
        elif keyword_hit_count:
            search_result = "FROZEN_KEYWORD_FOUND_BUT_NO_DIRECT_SELF_BUSINESS_PAGE_CONTEXT"
            qualification = "HELD_AFTER_ACTUAL_PDF_PAGE_SEARCH_INSUFFICIENT_DIRECT_CONTEXT"
            hold_reason = "KEYWORD_HITS_ARE_CONTEXT_ONLY_OR_NOT_SELF_BUSINESS"
        elif retrieval_success and not retrieval_failure:
            search_result = "ALL_REFERENCED_READABLE_PDFS_SEARCHED_NO_FROZEN_KEYWORD_HIT"
            qualification = "HELD_AFTER_ACTUAL_PDF_PAGE_SEARCH_TRUE_NEGATIVE"
            hold_reason = "NO_FROZEN_KEYWORD_HIT_IN_ANY_READABLE_REFERENCED_PDF"
        else:
            search_result = "ONE_OR_MORE_REFERENCED_PDFS_NOT_ACQUIRED_OR_UNREADABLE"
            qualification = "HELD_BY_REVIEWABLE_RETRIEVAL_OR_PARSE_FAILURE"
            hold_reason = ";".join(errors)
        core = {
            "qualification_row_id": source["qualification_row_id"], "task_id": TASK_ID,
            "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "company_id": source["company_id"], "security_code": source["security_code"],
            "track_code": source["track_code"], "selection_bucket": source["selection_bucket"],
            "search_terms": keyword, "attachment_ids": ";".join(ann_ids), "attachment_urls": ";".join(urls),
            "attachment_count": str(len(ann_ids)), "retrieval_success_count": str(retrieval_success),
            "retrieval_failure_count": str(retrieval_failure), "searched_pdf_count": str(searched_pdf_count),
            "searched_page_count": str(searched_pages), "keyword_hit_attachment_count": str(len(hit_attachment_ids)),
            "keyword_hit_pages": ";".join(keyword_pages), "keyword_hit_count": str(keyword_hit_count),
            "direct_context_hit_pages": ";".join(direct_pages), "direct_context_hit_count": str(direct_hit_count),
            "exact_context_sentences": "\n---CONTEXT---\n".join(exact_sentences[:12]),
            "negative_or_insufficient_context_samples": "\n---CONTEXT---\n".join(insufficient_samples[:8]),
            "page_search_result": search_result, "qualification_result": qualification,
            "failure_or_hold_reason": hold_reason, "verified_at": verified_at,
            "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
        }
        receipt_payload = json.dumps(core, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
        core["receipt_sha256"] = sha256_bytes(receipt_payload)
        rows.append(core)
    return rows
def parse_direct_context(row: dict[str, str]) -> tuple[str, int, str]:
    first = row["exact_context_sentences"].split("\n---CONTEXT---\n", 1)[0]
    match = re.match(r"([^:]+):p(\d+):(.*)", first, flags=re.S)
    if not match:
        raise RuntimeError(f"unparseable direct context: {row['qualification_row_id']}")
    return match.group(1), int(match.group(2)), re.sub(r"\s+", " ", match.group(3)).strip()
def date_from_adjunct(path: str) -> str:
    match = re.search(r"finalpage/(\d{4}-\d{2}-\d{2})/", path)
    return match.group(1) if match else ""
def descending_date(value: str) -> int:
    digits = "".join(char for char in (value or "") if char.isdigit())[:8]
    return -int(digits or "0")
def build_qualification_sources_and_conversions(
    industry_root: Path, download_rows: list[dict[str, Any]], potential_rows: list[dict[str, str]],
    project_root: Path,
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
    source_path = industry_root / "manifest" / "source_document.csv"
    conversion_path = industry_root / "manifest" / "conversion_status.csv"
    existing_sources = [row for row in read_csv(source_path) if not row["doc_id"].startswith("S-QUAL-AR-")]
    existing_conversions = [row for row in read_csv(conversion_path) if not row["source_doc_id"].startswith("S-QUAL-AR-")]
    source_headers = list(existing_sources[0])
    conversion_headers = list(existing_conversions[0])
    candidate_rows = read_csv(industry_root / "extracted" / "company_track_candidate_ledger.csv")
    candidate_by_code = {row["security_code"]: row for row in candidate_rows}
    pair_by_id = {row["qualification_row_id"]: row for row in potential_rows}
    new_sources: list[dict[str, str]] = []
    new_conversions: list[dict[str, str]] = []
    for receipt in download_rows:
        attachment_id = receipt["attachment_id"]
        doc_id = f"S-QUAL-AR-{attachment_id}"
        converted_path = industry_root / "converted" / "qualification_filings" / f"{attachment_id}__keyword_pages.txt"
        if not converted_path.exists():
            raise FileNotFoundError(converted_path)
        pair_ids = split_values(receipt["pair_ids"])
        tracks = sorted({pair_by_id[pair_id]["track_code"] for pair_id in pair_ids})
        codes = split_values(receipt["security_codes"])
        company_ids = sorted({candidate_by_code[code]["company_id"] for code in codes if code in candidate_by_code})
        raw_path = project_root / receipt["raw_path"]
        source_row = {
            "doc_id": doc_id, "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "doc_type": "OFFICIAL_QUALIFICATION_ANNUAL_REPORT",
            "title": receipt["announcement_title"] or f"2025年年度报告资格核验附件 {attachment_id}",
            "source_org": "巨潮资讯网/上市公司法定披露", "author": receipt["security_names"],
            "publish_date": date_from_adjunct(receipt["adjunct_path"]), "collected_at": receipt["attempted_at"],
            "source_url": receipt["source_url"], "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
            "subindustry_id": tracks[0] if len(tracks) == 1 else "CROSS_TRACK",
            "company_id": company_ids[0] if len(company_ids) == 1 else "",
            "raw_pool_path": "ana-data/cases/新能源案例/raw/qualification_filings/",
            "raw_file_path": receipt["raw_path"],
            "converted_text_path": converted_path.relative_to(project_root).as_posix(), "converted_markdown_path": "",
            "file_sha256": receipt["raw_sha256"], "file_name": raw_path.name,
            "file_size": receipt["raw_byte_count"], "detected_type": "PDF", "source_language": "zh-CN",
            "public_access_basis": "OFFICIAL_PUBLIC_DISCLOSURE_DIRECT_URL", "access_status": "PUBLIC_DIRECT",
            "source_level": "S", "sensitivity_screen": "LEGAL_PUBLIC_SCREENED_HIGH_LEVEL_ONLY",
            "legal_access_note": "巨潮资讯公开法定披露附件;REPAIR004仅执行冻结关键词页级检索;未绕过访问控制。",
            "doc_status": "QUALIFICATION_INPUT_REPAIR004",
            "processing_status": "PDF_ACQUIRED_READABLE_KEYWORD_PAGES_EXTRACTED_INDEXED_FOR_QUALIFICATION",
        }
        new_sources.append({key: source_row.get(key, "") for key in source_headers})
        conversion_row = {
            "conversion_id": f"CONV-{doc_id}", "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": doc_id,
            "raw_pool_path": source_row["raw_pool_path"], "raw_file_path": receipt["raw_path"],
            "raw_file_sha256": receipt["raw_sha256"], "detected_type": "PDF",
            "conversion_method": "PDFIUM_FULL_PAGE_TEXT_SEARCH_AND_KEYWORD_PAGE_EXTRACT",
            "parameters_summary": "all readable pages; frozen bucket keyword; exact hit page and sentence windows; no OCR; nuclear high-level public boundary",
            "converted_text_path": source_row["converted_text_path"], "converted_markdown_path": "",
            "converted_path": source_row["converted_text_path"], "converted_sha256": sha256_file(converted_path),
            "page_or_duration_count": receipt["page_count"], "status": "TEXT_SEARCHED_INDEXED_QUALIFICATION_COMPLETE",
            "error_code": "", "error_summary": "", "created_at": receipt["attempted_at"],
        }
        new_conversions.append({key: conversion_row.get(key, "") for key in conversion_headers})
    sources = existing_sources + new_sources
    conversions = existing_conversions + new_conversions
    if len({row["doc_id"] for row in sources}) != len(sources):
        raise RuntimeError("duplicate source doc id after REPAIR004 source merge")
    if len({row["source_doc_id"] for row in conversions}) != len(conversions):
        raise RuntimeError("conversion is not one-to-one after REPAIR004 source merge")
    write_csv(source_path, source_headers, sources)
    write_csv(conversion_path, conversion_headers, conversions)
    return sources, conversions
def build_repair004_evidence_and_candidates(
    industry_root: Path, pair_rows: list[dict[str, Any]], source_rows: list[dict[str, str]],
) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[tuple[str, str], str]]:
    evidence_path = industry_root / "evidence" / "evidence_fact_table.csv"
    evidence_rows = [row for row in read_csv(evidence_path) if not row["evidence_fact_id"].startswith("EVF-QUAL-")]
    evidence_headers = list(evidence_rows[0])
    candidate_path = industry_root / "extracted" / "company_track_candidate_ledger.csv"
    candidates = read_csv(candidate_path)
    source_by_id = {row["doc_id"]: row for row in source_rows}
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    new_evidence_id_by_pair: dict[tuple[str, str], str] = {}
    for receipt in pair_rows:
        if receipt["direct_context_hit_count"] == "0":
            continue
        pair = (receipt["company_id"], receipt["track_code"])
        candidate = candidate_by_pair[pair]
        attachment_id, page, sentence = parse_direct_context(receipt)
        doc_id = f"S-QUAL-AR-{attachment_id}"
        source = source_by_id[doc_id]
        evidence_id = f"EVF-QUAL-{receipt['track_code']}-{receipt['security_code']}-R004"
        new_evidence_id_by_pair[pair] = evidence_id
        evidence_row = {
            "evidence_fact_id": evidence_id, "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_id": doc_id,
            "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
            "subindustry_id": receipt["track_code"], "company_id": receipt["company_id"],
            "track_code": receipt["track_code"], "chain_node_id": CHAIN_NODES[(receipt["track_code"], receipt["selection_bucket"])],
            "subject_type": "COMPANY", "subject_id": receipt["company_id"],
            "source_text_path": source["converted_text_path"], "raw_pool_path": source["raw_pool_path"],
            "raw_file_sha256": source["file_sha256"], "source_page": str(page), "source_table_id": "",
            "source_sentence_index": "", "locator_type": "PDF_PAGE_AND_EXACT_CONTEXT",
            "locator_value": f"attachment_id={attachment_id};page={page};receipt={receipt['qualification_row_id']}",
            "evidence_text": sentence, "evidence_type": "OFFICIAL_ANNUAL_REPORT",
            "statement_type": "FACT", "business_dimension": "COMPANY_EXPOSURE",
            "research_dimension": "COMPANY_TRACK_QUALIFICATION", "numeric_value_raw": "",
            "metric_candidate_name": "", "metric_candidate_unit": "", "metric_period": "2025",
            "metric_date": "2025-12-31", "geography": "CN",
            "original_qualifier": "REPAIR004实际取得CNINFO附件并逐页检索;仅支持冻结桶的直接业务角色,不作份额、质量或投资判断。",
            "related_company_id": "", "viewpoint_id": "", "darkline_signal_flag": "NO",
            "confidence_level": "HIGH", "conclusion_strength": "DIRECT_FACT",
            "sensitivity_screen": "LEGAL_PUBLIC_SCREENED_HIGH_LEVEL_ONLY", "contradicts_evidence_fact_id": "",
            "normalization_status": "NORMALIZED", "processing_status": "READY",
            "data_status": "VERIFIED_PUBLIC", "review_status": "DRAFT_FOR_REVIEW",
        }
        evidence_rows.append({key: evidence_row.get(key, "") for key in evidence_headers})
        candidate.update({
            "chain_nodes": CHAIN_NODES[(receipt["track_code"], receipt["selection_bucket"])],
            "direct_business_source_id": doc_id,
            "direct_business_locator": f"2025年报第{page}页;attachment_id={attachment_id}",
            "evidence_grade": "S", "exposure_specificity": "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
            "latest_disclosed_period": "2025-12-31",
        })
    evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for evidence in evidence_rows:
        if evidence["subject_type"] == "COMPANY" and evidence["company_id"] and evidence["track_code"]:
            evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
    eligible_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for candidate in candidates:
        pair = (candidate["company_id"], candidate["track_code"])
        matches = [e for e in evidence_by_pair.get(pair, []) if e["doc_id"] == candidate["direct_business_source_id"]]
        if matches and candidate["evidence_grade"] in {"S", "A"} and candidate["exposure_specificity"] in {
            "SEGMENT_REVENUE_OR_ASSET_DISCLOSED", "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT", "GENERAL_DIRECT_BUSINESS_DESCRIPTION",
        }:
            candidate["candidate_state"] = "ELIGIBLE"
            candidate["selection_rank"] = ""
            candidate["tier"] = ""
            candidate["tie_break_rule"] = "source_grade>exposure_specificity>latest_period>publish_date>exchange_code>security_code"
            candidate["include_or_exclude_reason"] = (
                "REPAIR004实际取得年度报告PDF并逐页检索;直接业务角色、页码原句、S级主源及暴露具体性gate通过,进入同桶全部ELIGIBLE机械排序。"
            )
            eligible_groups[(candidate["track_code"], candidate["selection_bucket"])].append(candidate)
        else:
            candidate["candidate_state"] = "HELD_BY_EVIDENCE_GAP"
            candidate["selection_rank"] = ""
            candidate["tier"] = ""
    grade_order = {"S": 0, "A": 1}
    specificity_order = {
        "SEGMENT_REVENUE_OR_ASSET_DISCLOSED": 0,
        "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT": 1,
        "GENERAL_DIRECT_BUSINESS_DESCRIPTION": 2,
    }
    for group_rows in eligible_groups.values():
        group_rows.sort(key=lambda row: (
            grade_order.get(row["evidence_grade"], 9), specificity_order.get(row["exposure_specificity"], 9),
            descending_date(row["latest_disclosed_period"]),
            descending_date(source_by_id[row["direct_business_source_id"]]["publish_date"]),
            row["exchange_code"], row["security_code"],
        ))
        for rank, candidate in enumerate(group_rows, 1):
            candidate["selection_rank"] = str(rank)
            if rank == 1:
                candidate["candidate_state"] = "INCLUDED_T1"
                candidate["tier"] = "T1_PRIMARY"
            elif rank == 2:
                candidate["candidate_state"] = "INCLUDED_T2"
                candidate["tier"] = "T2_CANDIDATE"
            else:
                candidate["candidate_state"] = "ELIGIBLE_NOT_SELECTED_BATCH001"
                candidate["tier"] = ""
                candidate["include_or_exclude_reason"] += " 同桶排名超过2,保留为ELIGIBLE_NOT_SELECTED_BATCH001。"
    track_order = {"BATTERY": 0, "SOLAR": 1, "WIND": 2, "NUCLEAR": 3}
    bucket_order = {key: index for index, key in enumerate(TRACK_KEYWORDS)}
    state_order = {"INCLUDED_T1": 0, "INCLUDED_T2": 1, "ELIGIBLE_NOT_SELECTED_BATCH001": 2, "HELD_BY_EVIDENCE_GAP": 3}
    candidates.sort(key=lambda row: (
        track_order[row["track_code"]], bucket_order[(row["track_code"], row["selection_bucket"])],
        state_order.get(row["candidate_state"], 9), int(row["selection_rank"] or 999999),
        row["exchange_code"], row["security_code"],
    ))
    write_csv(evidence_path, evidence_headers, evidence_rows)
    write_csv(candidate_path, list(candidates[0]), candidates)
    return evidence_rows, candidates, new_evidence_id_by_pair
def update_qualification_funnel(
    industry_root: Path, pair_rows: list[dict[str, Any]], candidates: list[dict[str, str]],
    evidence_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
) -> list[dict[str, str]]:
    path = industry_root / "extracted" / "candidate_qualification_funnel.csv"
    funnel = read_csv(path)
    headers = list(funnel[0])
    pair_receipt = {(row["company_id"], row["track_code"]): row for row in pair_rows}
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    evidence_by_pair = defaultdict(list)
    for evidence in evidence_rows:
        if evidence["subject_type"] == "COMPANY":
            evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
    source_by_id = {row["doc_id"]: row for row in source_rows}
    eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
    for row in funnel:
        pair = (row["company_id"], row["track_code"])
        candidate = candidate_by_pair[pair]
        receipt = pair_receipt.get(pair)
        candidate_evidence = [e for e in evidence_by_pair.get(pair, []) if e["doc_id"] == candidate["direct_business_source_id"]]
        eligible = candidate["candidate_state"] in eligible_states
        if receipt:
            row["annual_report_retrieval_attempt"] = "CNINFO_ADJUNCT_PDF_GET_DEDUP_729_AND_FULL_PAGE_SEARCH"
            row["annual_report_retrieval_result"] = (
                f"ACQUIRED={receipt['retrieval_success_count']};FAILED={receipt['retrieval_failure_count']};"
                f"SEARCHED_PDFS={receipt['searched_pdf_count']};SEARCHED_PAGES={receipt['searched_page_count']}"
            )
            row["page_level_verification_attempt"] = "ACTUAL_PDF_OPEN_AND_FROZEN_KEYWORD_ALL_PAGE_SEARCH_WITH_BUCKET_ROLE_GATE"
            row["page_level_verification_result"] = receipt["qualification_result"]
            row["business_context_rule_result"] = (
                "PAGE_VERIFIED_DIRECT_BUSINESS_ROLE_CONFIRMED" if eligible else "PAGE_VERIFIED_INSUFFICIENT_OR_ROLE_MISMATCH"
            )
            row["replay_status"] = "ACTUAL_ATTACHMENT_RETRIEVAL_AND_PAGE_SEARCH_COMPLETED_FOR_PAIR"
        row["direct_business_source_id"] = candidate["direct_business_source_id"] if eligible else ""
        for gate in ["direct_source_gate", "locator_gate", "company_evidence_fact_gate", "source_grade_gate", "exposure_specificity_gate"]:
            row[gate] = "PASS" if eligible else "FAIL"
        row["evidence_fact_ids"] = ";".join(sorted(e["evidence_fact_id"] for e in candidate_evidence)) if eligible else ""
        row["failed_gates"] = "" if eligible else "DIRECT_BUSINESS_SOURCE;PAGE_OR_TEXT_LOCATOR;COMPANY_EVIDENCE_FACT;SOURCE_GRADE_S_OR_A;EXPOSURE_SPECIFICITY"
        row["eligibility_result"] = "ELIGIBLE" if eligible else "HELD_BY_EVIDENCE_GAP"
        row["eligible_rank_in_bucket"] = candidate["selection_rank"] if eligible else ""
        row["final_candidate_state"] = candidate["candidate_state"]
        source = source_by_id.get(candidate["direct_business_source_id"], {})
        row["mechanical_sort_key"] = "|".join([
            candidate["evidence_grade"], candidate["exposure_specificity"], candidate["latest_disclosed_period"],
            source.get("publish_date", ""), candidate["exchange_code"], candidate["security_code"],
        ])
        row["funnel_rule_version"] = "REPAIR004_ACTUAL_ADJUNCT_PDF_PAGE_SEARCH_AND_BUCKET_ROLE_GATE_V1"
        row["review_status"] = "DRAFT_FOR_REVIEW"
    write_csv(path, headers, funnel)
    return funnel
def update_classification_and_exposure(
    industry_root: Path, candidates: list[dict[str, str]], evidence_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
) -> list[dict[str, str]]:
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    evidence_by_pair = defaultdict(list)
    for evidence in evidence_rows:
        if evidence["subject_type"] == "COMPANY":
            evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
    source_by_id = {row["doc_id"]: row for row in source_rows}
    classification_path = industry_root / "extracted" / "classification_summary.csv"
    classifications = read_csv(classification_path)
    for row in classifications:
        pair = (row["company_id"], row["track_code"])
        candidate = candidate_by_pair[pair]
        matching = [e for e in evidence_by_pair.get(pair, []) if e["doc_id"] == candidate["direct_business_source_id"]]
        row["classification_reason"] = candidate["include_or_exclude_reason"]
        row["data_status"] = candidate["candidate_state"]
        row["review_status"] = "DRAFT_FOR_REVIEW"
        if matching:
            evidence = matching[0]
            source = source_by_id[evidence["doc_id"]]
            row["subject_type"] = "COMPANY_DIRECT_BUSINESS"
            row["source_doc_id"] = evidence["doc_id"]
            row["evidence_fact_id"] = evidence["evidence_fact_id"]
            row["chain_node_id"] = candidate["chain_nodes"]
            row["raw_pool_path"] = source["raw_pool_path"]
            row["raw_file_sha256"] = source["file_sha256"]
    write_csv(classification_path, list(classifications[0]), classifications)
    selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
    matrix_path = industry_root / "extracted" / "newenergy_company_exposure_matrix.csv"
    matrix_headers = list(read_csv(matrix_path)[0])
    matrix_rows = []
    for candidate in selected:
        pair = (candidate["company_id"], candidate["track_code"])
        evidence = next(e for e in evidence_by_pair[pair] if e["doc_id"] == candidate["direct_business_source_id"])
        row = {
            "mapping_id": f"MAP-{candidate['track_code']}-{candidate['security_code']}", "task_id": TASK_ID,
            "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "company_id": candidate["company_id"], "security_code": candidate["security_code"],
            "security_name": candidate["security_name"], "legal_name": candidate["legal_name"],
            "exchange_code": candidate["exchange_code"], "track_code": candidate["track_code"],
            "chain_nodes": candidate["chain_nodes"], "selection_bucket": candidate["selection_bucket"],
            "tier": candidate["tier"], "candidate_state": candidate["candidate_state"],
            "direct_business_source_id": candidate["direct_business_source_id"],
            "direct_business_locator": candidate["direct_business_locator"], "evidence_fact_id": evidence["evidence_fact_id"],
            "evidence_grade": candidate["evidence_grade"], "exposure_specificity": candidate["exposure_specificity"],
            "latest_disclosed_period": candidate["latest_disclosed_period"], "primary_region": "MAINLAND_CHINA",
            "scope_status": "CORE_SCOPE_DIRECT_BUSINESS", "coverage_claim": "NONE_INITIAL_CANDIDATE_POOL_ONLY",
            "data_status": "VERIFIED_PUBLIC", "review_status": "DRAFT_FOR_REVIEW",
        }
        matrix_rows.append({key: row.get(key, "") for key in matrix_headers})
    write_csv(matrix_path, matrix_headers, matrix_rows)
    return matrix_rows
def write_company_outputs_and_map(
    industry_root: Path, candidates: list[dict[str, str]], evidence_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
) -> list[dict[str, str]]:
    import newenergy_batch001_repair as base
    selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
    evidence_by_pair = defaultdict(list)
    for evidence in evidence_rows:
        if evidence["subject_type"] == "COMPANY":
            evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
    source_by_id = {row["doc_id"]: row for row in source_rows}
    track_meta = {
        "BATTERY": ("锂电", "01_锂电"), "SOLAR": ("光伏", "02_光伏"),
        "WIND": ("风电", "03_风电"), "NUCLEAR": ("核电", "04_核电"),
    }
    selected_by_track = defaultdict(list)
    facts: dict[tuple[str, str], tuple[dict[str, str], dict[str, str]]] = {}
    for candidate in selected:
        pair = (candidate["company_id"], candidate["track_code"])
        evidence = next(e for e in evidence_by_pair[pair] if e["doc_id"] == candidate["direct_business_source_id"])
        facts[pair] = (evidence, source_by_id[evidence["doc_id"]])
        selected_by_track[candidate["track_code"]].append(candidate)
    common_meta = (
        f"> 状态:`DRAFT_FOR_REVIEW`  \n> `task_id={TASK_ID}` · `case_id={CASE_ID}` · `batch_id={BATCH_ID}` · `run_id={RUN_ID}`  \n"
        "> `primary_region=MAINLAND_CHINA` · `global_comparator=SEPARATE_CONTEXT_ONLY` · `source_cutoff_at=2026-08-05T23:59:59+08:00`  \n"
        "> `coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY` · `valuation_market_interface=NOT_APPLICABLE_BATCH001`\n"
    )
    for track, rows in selected_by_track.items():
        title, directory = track_meta[track]
        lines = [
            f"# {title}相关企业", "", common_meta,
            "本页是完整候选发现池经实际年度报告附件逐页核验后的本批 A 股直接业务导航。每桶只保留机械排序前两名;T1/T2不表示质量或投资优先级。",
            "", "| 深度 | 桶 | 代码 | 公司 | 直接业务证据 | 主源 |", "|---|---|---:|---|---|---|",
        ]
        for candidate in rows:
            evidence, source = facts[(candidate["company_id"], track)]
            fact = evidence["evidence_text"].replace("|", "\\|")
            lines.append(
                f"| {candidate['tier']} | {candidate['selection_bucket']} | {candidate['security_code']} | {candidate['security_name']} | "
                f"{fact} | [{source['doc_id']}]({source['source_url']}),第{evidence['source_page']}页 |"
            )
        lines.extend([
            "", "## 解释限制", "",
            "- T1/T2 只代表本批机械排序后的研究深度,不代表公司质量、竞争排名、估值、交易或收益判断。",
            "- 同一公司同一赛道多个节点只计一次;多元化公司只标注主源直接支持的赛道暴露。",
            "- `coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY`。",
            "", "完整字段见 [候选台账](../../../../../extracted/company_track_candidate_ledger.csv)。", "",
        ])
        path = base.CASE_OUTPUTS / "核心文档" / "子行业图谱" / directory / "相关企业.md"
        path.write_text("\n".join(lines), encoding="utf-8")
    company_lines = [
        "# 新能源公司视图", "", common_meta,
        "32条映射来自法定年度报告直接业务证据;完整发现池实际取得附件并逐页检索后,每赛道四桶、每桶按冻结规则选择两家。T1/T2不表示公司优劣或投资优先级。", "",
    ]
    for track in ["BATTERY", "SOLAR", "WIND", "NUCLEAR"]:
        title, _ = track_meta[track]
        company_lines.extend([f"## {title}", ""])
        for candidate in selected_by_track[track]:
            evidence, source = facts[(candidate["company_id"], track)]
            company_lines.extend([
                f'<a id="company-{candidate["security_code"]}"></a>',
                f"### {candidate['security_name']}({candidate['security_code']},{candidate['tier']})", "",
                f"- 选择桶:{candidate['selection_bucket']}", f"- 直接节点:{candidate['chain_nodes']}",
                f"- 事实:{evidence['evidence_text']}",
                f"- 来源:[{source['doc_id']}]({source['source_url']}),第{evidence['source_page']}页",
                "- 限制:仅证明直接业务存在;不等同于赛道收入纯度、利润弹性、公司质量或投资结论。", "",
            ])
    (base.CASE_OUTPUTS / "新能源公司视图.md").write_text("\n".join(company_lines), encoding="utf-8")
    map_path = base.CASE_EVIDENCE / "case_evidence_map.csv"
    current_map = read_csv(map_path)
    map_headers = list(current_map[0])
    company_fact_ids = {
        evidence["evidence_fact_id"] for evidence in evidence_rows
        if evidence["subject_type"] == "COMPANY"
    }
    result = [
        row for row in current_map
        if row["evidence_fact_id"] not in company_fact_ids and not row["evidence_fact_id"].startswith("EVF-QUAL-")
    ]
    seq = 1
    def add(output_path: Path, evidence_id: str, conclusion: str, limit: str, strength: str = "DIRECT_FACT") -> None:
        nonlocal seq
        row = {
            "conclusion_evidence_map_id": f"CEM-R004-{seq:04d}", "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "conclusion_id": f"CONC-R004-{seq:04d}",
            "output_path": output_path.relative_to(Path.cwd()).as_posix(), "section_anchor": "PENDING_MATERIALIZATION",
            "conclusion_text": conclusion, "conclusion_strength": strength, "evidence_fact_id": evidence_id,
            "support_type": "SUPPORT", "contradiction_or_limit": limit, "review_status": "DRAFT_FOR_REVIEW",
        }
        result.append({key: row.get(key, "") for key in map_headers})
        seq += 1
    first_by_track = {}
    for candidate in selected:
        evidence, _source = facts[(candidate["company_id"], candidate["track_code"])]
        _title, directory = track_meta[candidate["track_code"]]
        add(base.CASE_OUTPUTS / "新能源公司视图.md", evidence["evidence_fact_id"], evidence["evidence_text"],
            "仅证明直接业务暴露;T1/T2不是质量、估值或投资排序。")
        add(base.CASE_OUTPUTS / "核心文档" / "子行业图谱" / directory / "相关企业.md",
            evidence["evidence_fact_id"], evidence["evidence_text"],
            "仅证明直接业务暴露;T1/T2不是质量、估值或投资排序。")
        first_by_track.setdefault(candidate["track_code"], (candidate, evidence))
    for track, (candidate, evidence) in first_by_track.items():
        _title, directory = track_meta[track]
        add(base.CASE_OUTPUTS / "新能源行业视图.md", evidence["evidence_fact_id"],
            f"{candidate['security_name']}的官方年报直接支持其{track}业务映射。", "公司例证不构成行业或公司全集。")
        add(base.CASE_OUTPUTS / "核心文档" / "子行业图谱" / directory / "产业链与技术路线.md",
            evidence["evidence_fact_id"], f"{candidate['security_name']}的公开产品/业务事实作为产业链节点例证。",
            "只支持公开产品/业务节点,不据此推导技术优劣、份额或投资结论。", "MECHANISM_ONLY")
    result = base.materialize_evidence_locators(result)
    write_csv(map_path, map_headers, result)
    return result
def update_package_manifests(
    industry_root: Path, source_rows: list[dict[str, str]], conversion_rows: list[dict[str, str]],
    evidence_rows: list[dict[str, str]], candidates: list[dict[str, str]], case_map: list[dict[str, str]],
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
    import newenergy_batch001_repair as base
    base.COLLECTED_AT = max((row["collected_at"] for row in source_rows if row.get("collected_at")), default="2026-08-06T00:00:00+08:00")
    base.build_input_manifests(source_rows)
    base.build_source_gap_audit(source_rows, evidence_rows)
    universe_rows = read_csv(industry_root / "extracted" / "a_share_universe.csv")
    base.rewrite_batch_summary(candidates, len(source_rows), len(conversion_rows), universe_rows)
    base.build_human_receipt(case_map, len(source_rows))
    output_rows = base.rebuild_output_manifest()
    artifact_rows = base.build_artifact_manifest(source_rows, output_rows, Path(__file__))
    return output_rows, artifact_rows
def finalize_repair004(
    industry_root: Path, potential_rows: list[dict[str, str]], download_rows: list[dict[str, Any]],
    pair_rows: list[dict[str, Any]], project_root: Path,
) -> dict[str, Any]:
    tool_dir = str((project_root / "ana-data" / "tools").resolve())
    if tool_dir not in sys.path:
        sys.path.insert(0, tool_dir)
    source_rows, conversion_rows = build_qualification_sources_and_conversions(
        industry_root, download_rows, potential_rows, project_root,
    )
    evidence_rows, candidates, _new_evidence = build_repair004_evidence_and_candidates(
        industry_root, pair_rows, source_rows,
    )
    funnel = update_qualification_funnel(industry_root, pair_rows, candidates, evidence_rows, source_rows)
    matrix_rows = update_classification_and_exposure(industry_root, candidates, evidence_rows, source_rows)
    case_map = write_company_outputs_and_map(industry_root, candidates, evidence_rows, source_rows)
    output_rows, artifact_rows = update_package_manifests(
        industry_root, source_rows, conversion_rows, evidence_rows, candidates, case_map,
    )
    selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
    state_counts = {state: sum(row["candidate_state"] == state for row in candidates) for state in sorted({row["candidate_state"] for row in candidates})}
    return {
        "status": "REPAIR004_FORMAL_PACKAGE_REBUILT_DRAFT_FOR_REVIEW",
        "sources": len(source_rows), "conversions": len(conversion_rows), "evidence_facts": len(evidence_rows),
        "candidate_pairs": len(candidates), "candidate_states": state_counts, "selected": len(selected),
        "exposure_matrix": len(matrix_rows), "funnel": len(funnel), "case_map": len(case_map),
        "outputs": len(output_rows), "artifacts_excluding_manifest_self": len(artifact_rows),
        "artifact_manifest_sha256": sha256_file(industry_root / "manifest" / "artifact_manifest.csv"),
    }
def validate_scope(
    potential_rows: list[dict[str, str]], items: list[dict[str, Any]], download_rows: list[dict[str, Any]],
    pair_rows: list[dict[str, Any]],
) -> None:
    if len(potential_rows) != 858:
        raise RuntimeError(f"frozen potential-direct pair count changed: {len(potential_rows)} != 858")
    if len(items) != 729:
        raise RuntimeError(f"frozen unique attachment count changed: {len(items)} != 729")
    if len({row["qualification_row_id"] for row in potential_rows}) != 858:
        raise RuntimeError("potential-direct pair ids are not unique")
    if len(download_rows) != 729 or len({row["attachment_id"] for row in download_rows}) != 729:
        raise RuntimeError("download receipt does not exactly cover the 729 attachments")
    if len(pair_rows) != 858 or len({row["qualification_row_id"] for row in pair_rows}) != 858:
        raise RuntimeError("pair receipt does not exactly cover the 858 frozen pairs")
    for row in download_rows:
        if row["pdf_readable"] == "YES":
            path = Path.cwd() / row["raw_path"]
            if not path.exists() or sha256_file(path) != row["raw_sha256"]:
                raise RuntimeError(f"download hash mismatch: {row['attachment_id']}")
def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--download-workers", type=int, default=12)
    parser.add_argument("--search-workers", type=int, default=max(2, min(8, os.cpu_count() or 4)))
    parser.add_argument("--acquire-only", action="store_true")
    parser.add_argument("--finalize", action="store_true")
    parser.add_argument("--force-http-receipt", action="store_true")
    args = parser.parse_args()
    project_root = Path.cwd().resolve()
    industry_root = find_industry_root(project_root)
    extracted_root = industry_root / "extracted"
    manifest_root = industry_root / "manifest"
    raw_dir = industry_root / "raw" / "qualification_filings"
    converted_dir = industry_root / "converted" / "qualification_filings"
    tmp_root = industry_root / CASE_ID / "tmp" / "repair004"
    raw_dir.mkdir(parents=True, exist_ok=True)
    converted_dir.mkdir(parents=True, exist_ok=True)
    tmp_root.mkdir(parents=True, exist_ok=True)
    funnel = read_csv(extracted_root / "candidate_qualification_funnel.csv")
    prior_pair_receipt = extracted_root / "candidate_page_qualification_receipt.csv"
    if prior_pair_receipt.exists():
        frozen_ids = {row["qualification_row_id"] for row in read_csv(prior_pair_receipt)}
        if len(frozen_ids) == 858:
            potential_rows = [row for row in funnel if row["qualification_row_id"] in frozen_ids]
        else:
            potential_rows = [row for row in funnel if row["business_context_rule_result"].startswith("PAGE_VERIFIED_")]
    else:
        potential_rows = [row for row in funnel if row["business_context_rule_result"] == POTENTIAL]
    metadata = load_announcement_metadata(industry_root)
    items = build_attachment_items(potential_rows, metadata, industry_root)
    download_receipt_path = manifest_root / "candidate_attachment_download_receipt.csv"
    download_rows = run_acquisition(
        items, raw_dir, download_receipt_path, project_root, args.download_workers, args.force_http_receipt,
    )
    if args.acquire_only:
        print(json.dumps({
            "status": "ACQUISITION_COMPLETE", "pairs": len(potential_rows), "attachments": len(items),
            "readable": sum(row["pdf_readable"] == "YES" for row in download_rows),
            "failed": sum(row["pdf_readable"] != "YES" for row in download_rows),
            "receipt_sha256": sha256_file(download_receipt_path),
        }, ensure_ascii=False), flush=True)
        return
    search_cache_path = tmp_root / "attachment_page_search_cache.json"
    search_results = run_search(
        items, download_rows, converted_dir, search_cache_path, project_root, args.search_workers,
    )
    verified_at = max((row["attempted_at"] for row in download_rows if row.get("attempted_at")), default=now_iso())
    pair_rows = build_pair_receipts(potential_rows, download_rows, search_results, verified_at)
    pair_receipt_path = extracted_root / "candidate_page_qualification_receipt.csv"
    write_csv(pair_receipt_path, PAIR_HEADERS, pair_rows)
    validate_scope(potential_rows, items, download_rows, pair_rows)
    if args.finalize:
        result = finalize_repair004(industry_root, potential_rows, download_rows, pair_rows, project_root)
        print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
        return
    print(json.dumps({
        "status": "REPAIR004_ATTACHMENT_AND_PAGE_SEARCH_COMPLETE",
        "pairs": len(potential_rows), "attachments": len(items),
        "readable_attachments": sum(row["pdf_readable"] == "YES" for row in download_rows),
        "failed_attachments": sum(row["pdf_readable"] != "YES" for row in download_rows),
        "direct_context_pairs": sum(row["direct_context_hit_count"] != "0" for row in pair_rows),
        "keyword_context_only_pairs": sum(row["page_search_result"] == "FROZEN_KEYWORD_FOUND_BUT_NO_DIRECT_SELF_BUSINESS_PAGE_CONTEXT" for row in pair_rows),
        "true_negative_pairs": sum(row["page_search_result"] == "ALL_REFERENCED_READABLE_PDFS_SEARCHED_NO_FROZEN_KEYWORD_HIT" for row in pair_rows),
        "retrieval_or_parse_failure_pairs": sum(row["page_search_result"] == "ONE_OR_MORE_REFERENCED_PDFS_NOT_ACQUIRED_OR_UNREADABLE" for row in pair_rows),
        "download_receipt_sha256": sha256_file(download_receipt_path),
        "pair_receipt_sha256": sha256_file(pair_receipt_path),
    }, ensure_ascii=False), flush=True)
if __name__ == "__main__":
    main()
ana-data/tools/newenergy_batch001_repair005.py
New file
@@ -0,0 +1,871 @@
#!/usr/bin/env python3
"""REPAIR005: close the remaining B1 company-self / target-bucket role defects.
This replay deliberately reuses the 729 CNINFO PDFs and the 858 page-search
receipts accepted by the independent REPAIR004 review.  It does not download,
expand, or replace research sources.  The only substantive change is a stricter
semantic role adjudication of the 50 page-search positives, followed by a full
858-row state replay, non-eligible field cleanup, mechanical reranking, and
deterministic downstream rebuild.
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
import newenergy_batch001_repair as base
import newenergy_batch001_repair004 as r4
TOOL_VERSION = "REPAIR-005"
ROLE_RULE_VERSION = "REPAIR005_COMPANY_SELF_TARGET_BUCKET_ROLE_GATE_V2_HUMAN_ADJUDICATED_DIRECT_SET"
REVIEW_STATUS = "DRAFT_FOR_REVIEW"
TASK_ID = r4.TASK_ID
CASE_ID = r4.CASE_ID
BATCH_ID = r4.BATCH_ID
RUN_ID = r4.RUN_ID
# The independent reviewer expressly allowed necessary human reading in
# REPAIR005.  Every one of the 50 prior regex-positive rows is therefore frozen
# here with an explicit semantic outcome.  Accepted rows prove a direct business
# of the listed company or a controlled subsidiary in the target bucket.  The
# remaining 808 rows are replayed from the already accepted full-page negative
# or context-only search receipt.
APPROVED_ROLE_DECISIONS: dict[str, str] = {
    "QUAL-00019": "CONTROLLED_SUBSIDIARY_TARGET_PRODUCT_MANUFACTURE",
    "QUAL-00061": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
    "QUAL-00072": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
    "QUAL-00103": "CONTROLLED_SUBSIDIARY_TARGET_PRODUCT_MANUFACTURE",
    "QUAL-00128": "DIRECT_TARGET_PRODUCT_PRODUCTION_AND_SALE",
    "QUAL-00129": "DIRECT_TARGET_PRODUCT_BUSINESS",
    "QUAL-00136": "DIRECT_TARGET_PRODUCT_PRODUCTION_AND_SALE",
    "QUAL-00141": "DIRECT_TARGET_PRODUCT_SALE",
    "QUAL-00148": "DIRECT_TARGET_PRODUCT_PRODUCTION",
    "QUAL-00149": "DIRECT_TARGET_RESOURCE_DEVELOPMENT_PRODUCTION_AND_SALE",
    "QUAL-00213": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
    "QUAL-00226": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
    "QUAL-00227": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
    "QUAL-00348": "DIRECT_TARGET_CELL_RESEARCH_PRODUCTION_AND_SALE",
    "QUAL-01029": "DIRECT_TARGET_SYSTEM_PRODUCT",
    "QUAL-01137": "DIRECT_TARGET_SYSTEM_PRODUCT",
    "QUAL-01625": "DIRECT_TARGET_WAFER_PRODUCTION",
    "QUAL-01639": "DIRECT_TARGET_SILICON_INGOT_AND_WAFER_PRODUCTION_AND_SALE",
    "QUAL-01732": "DIRECT_TARGET_WAFER_PRODUCT_BUSINESS",
    "QUAL-01846": "CONTROLLED_SUBSIDIARY_TARGET_COMPONENT_MANUFACTURE",
    "QUAL-01904": "DIRECT_TARGET_SPECIALTY_COMPONENT_PRODUCTION_AND_SALE",
    "QUAL-02192": "DIRECT_TARGET_COMPONENT_PRODUCT_SALE",
    "QUAL-02265": "DIRECT_TARGET_COMPONENT_PRODUCT_SALE",
    "QUAL-02299": "CONTROLLED_SUBSIDIARY_TARGET_COMPONENT_PRODUCTION_AND_SALE",
    "QUAL-02364": "CONTROLLED_SUBSIDIARY_TARGET_COMPONENT_RESEARCH_PRODUCTION_AND_SALE",
    "QUAL-02648": "DIRECT_TARGET_INVERTER_PRODUCT",
    "QUAL-02692": "CONTROLLED_SUBSIDIARY_TARGET_STATION_INVESTMENT_AND_OPERATION",
    "QUAL-02760": "CONTROLLED_SUBSIDIARY_TARGET_STATION_INVESTMENT_AND_OPERATION",
    "QUAL-02761": "DIRECT_TARGET_STATION_PROJECT_CONSTRUCTION_AND_OPERATION",
    "QUAL-02875": "CONTROLLED_SUBSIDIARY_TARGET_STATION_CONSTRUCTION_AND_OPERATION",
    "QUAL-02888": "DIRECT_TARGET_STATION_CONSTRUCTION_AND_OPERATION_WITH_REVENUE",
    "QUAL-02900": "CONTROLLED_SUBSIDIARY_TARGET_STATION_OPERATION",
    "QUAL-02956": "DIRECT_TARGET_STATION_EPC_AND_OPERATION_SERVICE",
    "QUAL-02982": "DIRECT_TARGET_STATION_CONSTRUCTION_OPERATION_AND_EXTERNAL_EXPANSION",
    "QUAL-02991": "DIRECT_TARGET_STATION_INVESTMENT_AND_OPERATION",
    "QUAL-03042": "DIRECT_TARGET_STATION_CONSTRUCTION_AND_OPERATION_WITH_REVENUE",
    "QUAL-03055": "DIRECT_TARGET_STATION_INVESTMENT_AND_OPERATION_WITH_REVENUE",
    "QUAL-03067": "DIRECT_TARGET_STATION_EPC_SERVICE",
    "QUAL-03199": "DIRECT_TARGET_WIND_COMPONENT_MANUFACTURE",
    "QUAL-03750": "DIRECT_TARGET_WIND_PROJECT_CONSTRUCTION_AND_OPERATION",
}
REJECTED_ROLE_DECISIONS: dict[str, str] = {
    "QUAL-00063": "ADJACENT_EQUIPMENT_AND_EPC_SERVICE_NOT_BATTERY_RESOURCE_OR_MAIN_MATERIAL",
    "QUAL-00223": "ADJACENT_INDUSTRIAL_GAS_SUPPLY_TO_BATTERY_MATERIAL_PROJECT",
    "QUAL-01640": "CUTTING_WIRE_AUXILIARY_NOT_SILICON_OR_WAFER_MANUFACTURE",
    "QUAL-01889": "COMPONENT_BACKSHEET_FILM_AND_EPOXY_AUXILIARY_NOT_CELL_OR_COMPONENT_MANUFACTURE",
    "QUAL-01901": "PHOTOVOLTAIC_GLASS_AUXILIARY_OR_POWER_GENERATION_NOT_CELL_OR_COMPONENT_MANUFACTURE",
    "QUAL-01962": "COMPONENT_ENCAPSULATION_MATERIAL_NOT_CELL_OR_COMPONENT_MANUFACTURE",
    "QUAL-02159": "COMPANY_PROCUREMENT_OF_COMPONENTS_AND_INVERTERS_NOT_TARGET_PRODUCT_BUSINESS",
    "QUAL-02499": "COMPONENTS_LISTED_AS_CONSUMED_RAW_MATERIAL_NOT_TARGET_PRODUCT_BUSINESS",
    "QUAL-02828": "INTERNAL_ROOFTOP_AND_STATION_SUPPORT_SERVICE_ONLY_WITHOUT_EXTERNAL_TARGET_BUSINESS",
    "QUAL-02852": "SELF_USE_TEXTILE_PROJECT_ONLY_WITHOUT_STATION_BUSINESS",
}
ROLE_HEADERS = [
    "role_adjudication_id", "qualification_row_id", "task_id", "case_id", "batch_id", "run_id",
    "company_id", "security_code", "security_name", "track_code", "selection_bucket",
    "prior_page_search_result", "prior_qualification_result", "prior_keyword_hit_count",
    "prior_direct_context_hit_count", "full_page_search_reused", "human_semantic_review",
    "company_self_gate", "target_bucket_role_gate", "adjacent_or_self_use_exclusion_gate",
    "final_role_decision", "decision_basis_code", "selected_attachment_id", "selected_page",
    "selected_exact_sentence", "rule_version", "adjudicated_at", "tool_version", "review_status",
    "receipt_sha256",
]
PRESTATE_HEADERS = [
    "snapshot_id", "blocker_id", "record_type", "company_id", "security_code", "track_code",
    "state", "subject_type", "direct_business_source_id", "direct_business_locator",
    "evidence_grade", "exposure_specificity", "evidence_fact_id", "chain_node_id",
    "classification_reason", "snapshot_status",
]
def role_receipt_path(industry_root: Path) -> Path:
    return industry_root / "extracted" / "candidate_role_adjudication_receipt.csv"
def prestate_path(industry_root: Path) -> Path:
    return industry_root / "manifest" / "repair005_pollution_prestate_snapshot.csv"
def validation_path(industry_root: Path) -> Path:
    return industry_root / "manifest" / "repair005_validation_receipt.json"
def stable_adjudicated_at(industry_root: Path) -> str:
    path = role_receipt_path(industry_root)
    if path.exists():
        rows = r4.read_csv(path)
        values = sorted({row.get("adjudicated_at", "") for row in rows if row.get("adjudicated_at")})
        if len(values) == 1:
            return values[0]
    return r4.now_iso()
def build_role_adjudication_receipt(
    industry_root: Path,
    pair_rows: list[dict[str, str]],
    candidates: list[dict[str, str]],
) -> list[dict[str, str]]:
    direct_ids = {row["qualification_row_id"] for row in pair_rows if int(row["direct_context_hit_count"]) > 0}
    frozen_decisions = set(APPROVED_ROLE_DECISIONS) | set(REJECTED_ROLE_DECISIONS)
    if len(pair_rows) != 858 or len({row["qualification_row_id"] for row in pair_rows}) != 858:
        raise RuntimeError("REPAIR005 requires the frozen 858 unique pair receipts")
    if len(direct_ids) != 50 or direct_ids != frozen_decisions:
        raise RuntimeError(
            f"the frozen 50 direct-context rows changed: direct={len(direct_ids)} "
            f"missing={sorted(direct_ids - frozen_decisions)} extra={sorted(frozen_decisions - direct_ids)}"
        )
    if set(APPROVED_ROLE_DECISIONS) & set(REJECTED_ROLE_DECISIONS):
        raise RuntimeError("approved and rejected semantic decision sets overlap")
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    adjudicated_at = stable_adjudicated_at(industry_root)
    results: list[dict[str, str]] = []
    for source in sorted(pair_rows, key=lambda row: row["qualification_row_id"]):
        qid = source["qualification_row_id"]
        candidate = candidate_by_pair[(source["company_id"], source["track_code"])]
        attachment_id = ""
        page = ""
        sentence = ""
        if qid in APPROVED_ROLE_DECISIONS:
            attachment_id, page_number, sentence = r4.parse_direct_context(source)
            page = str(page_number)
            company_gate = "PASS"
            target_gate = "PASS"
            exclusion_gate = "PASS_NO_EXCLUSION_TRIGGERED"
            decision = "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE"
            basis = APPROVED_ROLE_DECISIONS[qid]
            human_review = "YES_ALL_PRIOR_DIRECT_CONTEXTS_REVIEWED"
        elif qid in REJECTED_ROLE_DECISIONS:
            attachment_id, page_number, sentence = r4.parse_direct_context(source)
            page = str(page_number)
            company_gate = "FAIL_OR_NOT_SAME_TARGET_ROLE"
            target_gate = "FAIL"
            exclusion_gate = "FAIL_EXCLUSION_TRIGGERED"
            decision = "HELD_ROLE_MISMATCH_ADJACENT_PROCUREMENT_OR_SELF_USE"
            basis = REJECTED_ROLE_DECISIONS[qid]
            human_review = "YES_ALL_PRIOR_DIRECT_CONTEXTS_REVIEWED"
        else:
            company_gate = "FAIL_NO_PRIOR_DIRECT_SELF_CONTEXT"
            target_gate = "FAIL_NO_PRIOR_DIRECT_TARGET_ROLE_CONTEXT"
            exclusion_gate = "NOT_APPLICABLE_NO_DIRECT_CONTEXT"
            if source["page_search_result"] == "ALL_REFERENCED_READABLE_PDFS_SEARCHED_NO_FROZEN_KEYWORD_HIT":
                decision = "HELD_TRUE_NEGATIVE_AFTER_FULL_PAGE_SEARCH"
                basis = "NO_FROZEN_KEYWORD_HIT_IN_REFERENCED_READABLE_PDFS"
            elif source["page_search_result"] == "FROZEN_KEYWORD_FOUND_BUT_NO_DIRECT_SELF_BUSINESS_PAGE_CONTEXT":
                decision = "HELD_KEYWORD_CONTEXT_ONLY_AFTER_FULL_PAGE_SEARCH"
                basis = "KEYWORD_CONTEXT_DID_NOT_PASS_COMPANY_SELF_AND_TARGET_ROLE_GATE"
            else:
                decision = "HELD_RETRIEVAL_OR_PARSE_GAP"
                basis = "REPAIR004_REVIEWABLE_RETRIEVAL_OR_PARSE_FAILURE"
            human_review = "NOT_REQUIRED_PRIOR_FULL_PAGE_GATE_FOUND_NO_DIRECT_CONTEXT"
        core: dict[str, str] = {
            "role_adjudication_id": f"ROLE-R005-{qid}", "qualification_row_id": qid,
            "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "company_id": source["company_id"], "security_code": source["security_code"],
            "security_name": candidate["security_name"], "track_code": source["track_code"],
            "selection_bucket": source["selection_bucket"],
            "prior_page_search_result": source["page_search_result"],
            "prior_qualification_result": source["qualification_result"],
            "prior_keyword_hit_count": source["keyword_hit_count"],
            "prior_direct_context_hit_count": source["direct_context_hit_count"],
            "full_page_search_reused": "YES_REPAIR004_ACCEPTED_729_PDFS_858_PAIR_SEARCH",
            "human_semantic_review": human_review,
            "company_self_gate": company_gate, "target_bucket_role_gate": target_gate,
            "adjacent_or_self_use_exclusion_gate": exclusion_gate,
            "final_role_decision": decision, "decision_basis_code": basis,
            "selected_attachment_id": attachment_id, "selected_page": page,
            "selected_exact_sentence": sentence, "rule_version": ROLE_RULE_VERSION,
            "adjudicated_at": adjudicated_at, "tool_version": TOOL_VERSION,
            "review_status": REVIEW_STATUS,
        }
        payload = json.dumps(core, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
        core["receipt_sha256"] = r4.sha256_bytes(payload)
        results.append({key: core.get(key, "") for key in ROLE_HEADERS})
    r4.write_csv(role_receipt_path(industry_root), ROLE_HEADERS, results)
    return results
def write_prestate_snapshot_once(
    industry_root: Path,
    candidates: list[dict[str, str]],
    classifications: list[dict[str, str]],
) -> list[dict[str, str]]:
    path = prestate_path(industry_root)
    if path.exists():
        prior = r4.read_csv(path)
        rows = [
            row for row in prior
            if (
                row["record_type"] == "CANDIDATE_LEDGER"
                and bool(row["direct_business_source_id"])
                and row["evidence_grade"] in {"S", "A"}
            ) or (
                row["record_type"] == "CLASSIFICATION_SUMMARY"
                and (
                    row["subject_type"] == "COMPANY_DIRECT_BUSINESS"
                    or bool(row["evidence_fact_id"])
                )
            )
        ]
        if len(rows) != len(prior):
            r4.write_csv(path, PRESTATE_HEADERS, rows)
        return rows
    rows: list[dict[str, str]] = []
    for candidate in candidates:
        held = candidate["candidate_state"] == "HELD_BY_EVIDENCE_GAP"
        polluted = (
            held
            and bool(candidate.get("direct_business_source_id", ""))
            and candidate.get("evidence_grade", "") in {"S", "A"}
        )
        if not polluted:
            continue
        rows.append({
            "snapshot_id": f"PRE-R005-LEDGER-{candidate['track_code']}-{candidate['security_code']}",
            "blocker_id": "B1-R004-B", "record_type": "CANDIDATE_LEDGER",
            "company_id": candidate["company_id"], "security_code": candidate["security_code"],
            "track_code": candidate["track_code"], "state": candidate["candidate_state"],
            "subject_type": "", "direct_business_source_id": candidate["direct_business_source_id"],
            "direct_business_locator": candidate["direct_business_locator"],
            "evidence_grade": candidate["evidence_grade"],
            "exposure_specificity": candidate["exposure_specificity"], "evidence_fact_id": "",
            "chain_node_id": candidate["chain_nodes"],
            "classification_reason": candidate["include_or_exclude_reason"],
            "snapshot_status": "FROZEN_PRE_REPAIR005_POLLUTION_EVIDENCE",
        })
    for row in classifications:
        held = row["data_status"] == "HELD_BY_EVIDENCE_GAP"
        polluted = held and (
            row.get("subject_type", "") == "COMPANY_DIRECT_BUSINESS"
            or bool(row.get("evidence_fact_id", ""))
        )
        if not polluted:
            continue
        rows.append({
            "snapshot_id": f"PRE-R005-CLASS-{row['track_code']}-{row['company_id']}",
            "blocker_id": "B1-R004-B", "record_type": "CLASSIFICATION_SUMMARY",
            "company_id": row["company_id"], "security_code": row["company_id"].split(":")[-1],
            "track_code": row["track_code"], "state": row["data_status"],
            "subject_type": row["subject_type"], "direct_business_source_id": row["source_doc_id"],
            "direct_business_locator": "", "evidence_grade": "", "exposure_specificity": "",
            "evidence_fact_id": row["evidence_fact_id"], "chain_node_id": row["chain_node_id"],
            "classification_reason": row["classification_reason"],
            "snapshot_status": "FROZEN_PRE_REPAIR005_POLLUTION_EVIDENCE",
        })
    if not rows:
        raise RuntimeError("expected REPAIR004 polluted prestate was not found before REPAIR005")
    r4.write_csv(path, PRESTATE_HEADERS, rows)
    return rows
def clear_noneligible_candidate(candidate: dict[str, str], reason: str) -> None:
    candidate["chain_nodes"] = ""
    candidate["direct_business_source_id"] = ""
    candidate["direct_business_locator"] = ""
    candidate["evidence_grade"] = ""
    candidate["exposure_specificity"] = ""
    candidate["latest_disclosed_period"] = ""
    candidate["candidate_state"] = "HELD_BY_EVIDENCE_GAP"
    candidate["selection_rank"] = ""
    candidate["tier"] = ""
    candidate["tie_break_rule"] = ""
    candidate["include_or_exclude_reason"] = reason
def build_evidence_and_candidates(
    industry_root: Path,
    role_rows: list[dict[str, str]],
    source_rows: list[dict[str, str]],
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
    evidence_path = industry_root / "evidence" / "evidence_fact_table.csv"
    prior_evidence = r4.read_csv(evidence_path)
    evidence_headers = list(prior_evidence[0])
    evidence_rows = [row for row in prior_evidence if not row["evidence_fact_id"].startswith("EVF-QUAL-")]
    candidate_path = industry_root / "extracted" / "company_track_candidate_ledger.csv"
    candidates = r4.read_csv(candidate_path)
    source_by_id = {row["doc_id"]: row for row in source_rows}
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    role_by_pair = {(row["company_id"], row["track_code"]): row for row in role_rows}
    for role in role_rows:
        if role["final_role_decision"] != "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE":
            continue
        pair = (role["company_id"], role["track_code"])
        candidate = candidate_by_pair[pair]
        attachment_id = role["selected_attachment_id"]
        page = role["selected_page"]
        doc_id = f"S-QUAL-AR-{attachment_id}"
        source = source_by_id[doc_id]
        evidence_id = f"EVF-QUAL-{role['track_code']}-{role['security_code']}-R005"
        evidence = {
            "evidence_fact_id": evidence_id, "task_id": TASK_ID, "case_id": CASE_ID,
            "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_id": doc_id,
            "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
            "subindustry_id": role["track_code"], "company_id": role["company_id"],
            "track_code": role["track_code"],
            "chain_node_id": r4.CHAIN_NODES[(role["track_code"], role["selection_bucket"])],
            "subject_type": "COMPANY", "subject_id": role["company_id"],
            "source_text_path": source["converted_text_path"], "raw_pool_path": source["raw_pool_path"],
            "raw_file_sha256": source["file_sha256"], "source_page": page, "source_table_id": "",
            "source_sentence_index": "", "locator_type": "PDF_PAGE_AND_EXACT_CONTEXT",
            "locator_value": f"attachment_id={attachment_id};page={page};receipt={role['qualification_row_id']};role_receipt={role['role_adjudication_id']}",
            "evidence_text": role["selected_exact_sentence"], "evidence_type": "OFFICIAL_ANNUAL_REPORT",
            "statement_type": "FACT", "business_dimension": "COMPANY_EXPOSURE",
            "research_dimension": "COMPANY_TRACK_QUALIFICATION", "numeric_value_raw": "",
            "metric_candidate_name": "", "metric_candidate_unit": "", "metric_period": "2025",
            "metric_date": "2025-12-31", "geography": "CN",
            "original_qualifier": (
                "REPAIR005复用已审核的CNINFO年度报告与全页检索回执,并对全部50条先前direct-context做公司自身/"
                "受控子公司与目标桶角色人读裁决;仅支持直接业务事实,不作份额、质量或投资判断。"
            ),
            "related_company_id": "", "viewpoint_id": "", "darkline_signal_flag": "NO",
            "confidence_level": "HIGH", "conclusion_strength": "DIRECT_FACT",
            "sensitivity_screen": "LEGAL_PUBLIC_SCREENED_HIGH_LEVEL_ONLY",
            "contradicts_evidence_fact_id": "", "normalization_status": "NORMALIZED",
            "processing_status": "READY", "data_status": "VERIFIED_PUBLIC",
            "review_status": REVIEW_STATUS,
        }
        evidence_rows.append({key: evidence.get(key, "") for key in evidence_headers})
        candidate.update({
            "chain_nodes": r4.CHAIN_NODES[(role["track_code"], role["selection_bucket"])],
            "direct_business_source_id": doc_id,
            "direct_business_locator": f"2025年报第{page}页;attachment_id={attachment_id};role_receipt={role['role_adjudication_id']}",
            "evidence_grade": "S",
            "exposure_specificity": "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
            "latest_disclosed_period": "2025-12-31",
        })
    evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for evidence in evidence_rows:
        if evidence["subject_type"] == "COMPANY" and evidence["company_id"] and evidence["track_code"]:
            evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
    eligible_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    valid_specificity = {
        "SEGMENT_REVENUE_OR_ASSET_DISCLOSED",
        "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
        "GENERAL_DIRECT_BUSINESS_DESCRIPTION",
    }
    for candidate in candidates:
        pair = (candidate["company_id"], candidate["track_code"])
        matching = [
            evidence for evidence in evidence_by_pair.get(pair, [])
            if candidate["direct_business_source_id"] and evidence["doc_id"] == candidate["direct_business_source_id"]
        ]
        eligible = bool(matching) and candidate["evidence_grade"] in {"S", "A"} and candidate["exposure_specificity"] in valid_specificity
        if eligible:
            candidate["candidate_state"] = "ELIGIBLE"
            candidate["selection_rank"] = ""
            candidate["tier"] = ""
            candidate["tie_break_rule"] = "source_grade>exposure_specificity>latest_period>publish_date>exchange_code>security_code"
            if pair in role_by_pair:
                candidate["include_or_exclude_reason"] = (
                    "REPAIR005复用年度报告PDF与全页检索,对先前direct-context执行公司自身/受控子公司和目标桶角色语义裁决;"
                    "S级主源、页码原句与全部资格gate通过,进入同桶ELIGIBLE机械排序。"
                )
            else:
                candidate["include_or_exclude_reason"] = (
                    "设计前序已登记直接业务主源与证据事实在REPAIR005重放中保持有效;全部资格gate通过,进入同桶ELIGIBLE机械排序。"
                )
            eligible_groups[(candidate["track_code"], candidate["selection_bucket"])].append(candidate)
        else:
            role = role_by_pair.get(pair)
            if role:
                reason = (
                    f"REPAIR005全量重放结论={role['final_role_decision']};"
                    f"依据={role['decision_basis_code']};未通过公司自身及目标桶直接业务证据闸门,保持HELD。"
                )
            else:
                reason = "REPAIR005重放未找到可与候选直接业务字段闭合的S/A级公司证据,清除全部资格派生字段并保持HELD。"
            clear_noneligible_candidate(candidate, reason)
    grade_order = {"S": 0, "A": 1}
    specificity_order = {
        "SEGMENT_REVENUE_OR_ASSET_DISCLOSED": 0,
        "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT": 1,
        "GENERAL_DIRECT_BUSINESS_DESCRIPTION": 2,
    }
    for group_rows in eligible_groups.values():
        group_rows.sort(key=lambda row: (
            grade_order.get(row["evidence_grade"], 9),
            specificity_order.get(row["exposure_specificity"], 9),
            r4.descending_date(row["latest_disclosed_period"]),
            r4.descending_date(source_by_id[row["direct_business_source_id"]]["publish_date"]),
            row["exchange_code"], row["security_code"],
        ))
        for rank, candidate in enumerate(group_rows, 1):
            candidate["selection_rank"] = str(rank)
            if rank == 1:
                candidate["candidate_state"] = "INCLUDED_T1"
                candidate["tier"] = "T1_PRIMARY"
            elif rank == 2:
                candidate["candidate_state"] = "INCLUDED_T2"
                candidate["tier"] = "T2_CANDIDATE"
            else:
                candidate["candidate_state"] = "ELIGIBLE_NOT_SELECTED_BATCH001"
                candidate["tier"] = ""
                candidate["include_or_exclude_reason"] += " 同桶排名超过2,保留为ELIGIBLE_NOT_SELECTED_BATCH001。"
    track_order = {"BATTERY": 0, "SOLAR": 1, "WIND": 2, "NUCLEAR": 3}
    bucket_order = {key: index for index, key in enumerate(r4.TRACK_KEYWORDS)}
    state_order = {
        "INCLUDED_T1": 0, "INCLUDED_T2": 1,
        "ELIGIBLE_NOT_SELECTED_BATCH001": 2, "HELD_BY_EVIDENCE_GAP": 3,
    }
    candidates.sort(key=lambda row: (
        track_order[row["track_code"]], bucket_order[(row["track_code"], row["selection_bucket"])],
        state_order.get(row["candidate_state"], 9), int(row["selection_rank"] or 999999),
        row["exchange_code"], row["security_code"],
    ))
    r4.write_csv(evidence_path, evidence_headers, evidence_rows)
    r4.write_csv(candidate_path, list(candidates[0]), candidates)
    return evidence_rows, candidates
def update_funnel(
    industry_root: Path,
    role_rows: list[dict[str, str]],
    candidates: list[dict[str, str]],
    evidence_rows: list[dict[str, str]],
    source_rows: list[dict[str, str]],
) -> list[dict[str, str]]:
    path = industry_root / "extracted" / "candidate_qualification_funnel.csv"
    funnel = r4.read_csv(path)
    role_by_pair = {(row["company_id"], row["track_code"]): row for row in role_rows}
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    source_by_id = {row["doc_id"]: row for row in source_rows}
    evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for evidence in evidence_rows:
        if evidence["subject_type"] == "COMPANY":
            evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
    eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
    for row in funnel:
        pair = (row["company_id"], row["track_code"])
        candidate = candidate_by_pair[pair]
        role = role_by_pair.get(pair)
        eligible = candidate["candidate_state"] in eligible_states
        matching = [
            evidence for evidence in evidence_by_pair.get(pair, [])
            if candidate["direct_business_source_id"] and evidence["doc_id"] == candidate["direct_business_source_id"]
        ]
        if role:
            row["page_level_verification_attempt"] = "REUSE_REPAIR004_FULL_PAGE_SEARCH_PLUS_REPAIR005_COMPANY_SELF_TARGET_BUCKET_ROLE_ADJUDICATION"
            row["page_level_verification_result"] = role["final_role_decision"]
            row["business_context_rule_result"] = (
                "REPAIR005_COMPANY_SELF_TARGET_BUCKET_ROLE_CONFIRMED"
                if role["final_role_decision"] == "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE"
                else "REPAIR005_HELD_BY_ROLE_CONTEXT_OR_SEARCH_RESULT"
            )
            row["replay_status"] = "REPAIR005_FULL_858_ROLE_STATE_REPLAY_COMPLETED"
        row["direct_business_source_id"] = candidate["direct_business_source_id"] if eligible else ""
        for gate in ["direct_source_gate", "locator_gate", "company_evidence_fact_gate", "source_grade_gate", "exposure_specificity_gate"]:
            row[gate] = "PASS" if eligible else "FAIL"
        row["evidence_fact_ids"] = ";".join(sorted(e["evidence_fact_id"] for e in matching)) if eligible else ""
        row["failed_gates"] = "" if eligible else "DIRECT_BUSINESS_SOURCE;PAGE_OR_TEXT_LOCATOR;COMPANY_EVIDENCE_FACT;SOURCE_GRADE_S_OR_A;EXPOSURE_SPECIFICITY"
        row["eligibility_result"] = "ELIGIBLE" if eligible else "HELD_BY_EVIDENCE_GAP"
        row["eligible_rank_in_bucket"] = candidate["selection_rank"] if eligible else ""
        row["final_candidate_state"] = candidate["candidate_state"]
        if eligible:
            source = source_by_id[candidate["direct_business_source_id"]]
            row["mechanical_sort_key"] = "|".join([
                candidate["evidence_grade"], candidate["exposure_specificity"],
                candidate["latest_disclosed_period"], source["publish_date"],
                candidate["exchange_code"], candidate["security_code"],
            ])
        else:
            row["mechanical_sort_key"] = ""
        row["funnel_rule_version"] = ROLE_RULE_VERSION
        row["review_status"] = REVIEW_STATUS
    r4.write_csv(path, list(funnel[0]), funnel)
    return funnel
def update_classification_and_matrix(
    industry_root: Path,
    candidates: list[dict[str, str]],
    evidence_rows: list[dict[str, str]],
    source_rows: list[dict[str, str]],
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    source_by_id = {row["doc_id"]: row for row in source_rows}
    evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for evidence in evidence_rows:
        if evidence["subject_type"] == "COMPANY":
            evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
    classification_path = industry_root / "extracted" / "classification_summary.csv"
    classifications = r4.read_csv(classification_path)
    eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
    for row in classifications:
        pair = (row["company_id"], row["track_code"])
        candidate = candidate_by_pair[pair]
        eligible = candidate["candidate_state"] in eligible_states
        matching = [
            evidence for evidence in evidence_by_pair.get(pair, [])
            if candidate["direct_business_source_id"] and evidence["doc_id"] == candidate["direct_business_source_id"]
        ]
        row["classification_reason"] = candidate["include_or_exclude_reason"]
        row["data_status"] = candidate["candidate_state"]
        row["review_status"] = REVIEW_STATUS
        if eligible and matching:
            evidence = matching[0]
            source = source_by_id[evidence["doc_id"]]
            row["subject_type"] = "COMPANY_DIRECT_BUSINESS"
            row["source_doc_id"] = evidence["doc_id"]
            row["evidence_fact_id"] = evidence["evidence_fact_id"]
            row["chain_node_id"] = candidate["chain_nodes"]
            row["raw_pool_path"] = source["raw_pool_path"]
            row["raw_file_sha256"] = source["file_sha256"]
        else:
            discovery_source = source_by_id.get(candidate["candidate_source_id"], {})
            row["subject_type"] = "COMPANY_QUERY_HIT"
            row["source_doc_id"] = discovery_source.get("doc_id", "")
            row["evidence_fact_id"] = ""
            row["chain_node_id"] = ""
            row["raw_pool_path"] = discovery_source.get("raw_pool_path", "")
            row["raw_file_sha256"] = discovery_source.get("file_sha256", "")
    r4.write_csv(classification_path, list(classifications[0]), classifications)
    selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
    matrix_path = industry_root / "extracted" / "newenergy_company_exposure_matrix.csv"
    matrix_headers = list(r4.read_csv(matrix_path)[0])
    matrix_rows: list[dict[str, str]] = []
    for candidate in selected:
        pair = (candidate["company_id"], candidate["track_code"])
        evidence = next(
            evidence for evidence in evidence_by_pair[pair]
            if evidence["doc_id"] == candidate["direct_business_source_id"]
        )
        row = {
            "mapping_id": f"MAP-{candidate['track_code']}-{candidate['security_code']}",
            "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "company_id": candidate["company_id"], "security_code": candidate["security_code"],
            "security_name": candidate["security_name"], "legal_name": candidate["legal_name"],
            "exchange_code": candidate["exchange_code"], "track_code": candidate["track_code"],
            "chain_nodes": candidate["chain_nodes"], "selection_bucket": candidate["selection_bucket"],
            "tier": candidate["tier"], "candidate_state": candidate["candidate_state"],
            "direct_business_source_id": candidate["direct_business_source_id"],
            "direct_business_locator": candidate["direct_business_locator"],
            "evidence_fact_id": evidence["evidence_fact_id"], "evidence_grade": candidate["evidence_grade"],
            "exposure_specificity": candidate["exposure_specificity"],
            "latest_disclosed_period": candidate["latest_disclosed_period"],
            "primary_region": "MAINLAND_CHINA", "scope_status": "CORE_SCOPE_DIRECT_BUSINESS",
            "coverage_claim": "NONE_INITIAL_CANDIDATE_POOL_ONLY",
            "data_status": "VERIFIED_PUBLIC", "review_status": REVIEW_STATUS,
        }
        matrix_rows.append({key: row.get(key, "") for key in matrix_headers})
    r4.write_csv(matrix_path, matrix_headers, matrix_rows)
    return classifications, matrix_rows
def build_manifests(
    industry_root: Path,
    source_rows: list[dict[str, str]],
    conversion_rows: list[dict[str, str]],
    evidence_rows: list[dict[str, str]],
    candidates: list[dict[str, str]],
    case_map: list[dict[str, str]],
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
    base.COLLECTED_AT = max(
        (row["collected_at"] for row in source_rows if row.get("collected_at")),
        default="2026-08-06T00:00:00+08:00",
    )
    base.ARTIFACT_TOOL_VERSION = TOOL_VERSION
    base.ARTIFACT_PARAMETERS_SUMMARY = (
        "B1 REPAIR005; reuse 729 acquired PDFs; replay 858 pairs through the company-self and "
        "target-bucket semantic role gate; clear non-eligible derived fields and dangling evidence FKs; "
        "mechanical rerank; exact-set hash coverage"
    )
    base.ARTIFACT_STATUS = "READY_FOR_REPAIR005_FOCUSED_REREVIEW"
    base.build_input_manifests(source_rows)
    base.build_source_gap_audit(source_rows, evidence_rows)
    universe_rows = r4.read_csv(industry_root / "extracted" / "a_share_universe.csv")
    base.rewrite_batch_summary(candidates, len(source_rows), len(conversion_rows), universe_rows)
    base.build_human_receipt(case_map, len(source_rows))
    output_rows = base.rebuild_output_manifest()
    artifact_rows = base.build_artifact_manifest(source_rows, output_rows, Path(__file__))
    return output_rows, artifact_rows
def split_evidence_ids(value: str) -> list[str]:
    return [item.strip() for item in (value or "").split(";") if item.strip()]
def validate_and_write_receipt(
    industry_root: Path,
    role_rows: list[dict[str, str]],
    prestate_rows: list[dict[str, str]],
    source_rows: list[dict[str, str]],
    conversion_rows: list[dict[str, str]],
    evidence_rows: list[dict[str, str]],
    candidates: list[dict[str, str]],
    funnel: list[dict[str, str]],
    classifications: list[dict[str, str]],
    matrix_rows: list[dict[str, str]],
    case_map: list[dict[str, str]],
) -> dict[str, Any]:
    errors: list[str] = []
    eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
    evidence_ids = {row["evidence_fact_id"] for row in evidence_rows}
    source_ids = {row["doc_id"] for row in source_rows}
    conversion_ids = {row["source_doc_id"] for row in conversion_rows}
    candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
    role_counts = Counter(row["final_role_decision"] for row in role_rows)
    expected_role_counts = {
        "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE": 40,
        "HELD_ROLE_MISMATCH_ADJACENT_PROCUREMENT_OR_SELF_USE": 10,
        "HELD_KEYWORD_CONTEXT_ONLY_AFTER_FULL_PAGE_SEARCH": 221,
        "HELD_TRUE_NEGATIVE_AFTER_FULL_PAGE_SEARCH": 587,
    }
    if dict(role_counts) != expected_role_counts:
        errors.append(f"role distribution mismatch: {dict(role_counts)}")
    if len(role_rows) != 858 or len({row["qualification_row_id"] for row in role_rows}) != 858:
        errors.append("role adjudication receipt is not exact 858")
    if any(row["rule_version"] != ROLE_RULE_VERSION or row["tool_version"] != TOOL_VERSION for row in role_rows):
        errors.append("role receipt rule/tool version mismatch")
    candidate_counts = Counter(row["candidate_state"] for row in candidates)
    expected_candidate_counts = {
        "INCLUDED_T1": 16,
        "INCLUDED_T2": 16,
        "ELIGIBLE_NOT_SELECTED_BATCH001": 40,
        "HELD_BY_EVIDENCE_GAP": 4479,
    }
    if dict(candidate_counts) != expected_candidate_counts:
        errors.append(f"candidate distribution mismatch: {dict(candidate_counts)}")
    if len(candidates) != 4551 or len({(row["company_id"], row["track_code"]) for row in candidates}) != 4551:
        errors.append("candidate ledger pair set is not exact 4551")
    noneligible_derived_fields = [
        "chain_nodes", "direct_business_source_id", "direct_business_locator", "evidence_grade",
        "exposure_specificity", "latest_disclosed_period", "selection_rank", "tier", "tie_break_rule",
    ]
    polluted_candidates = [
        row for row in candidates if row["candidate_state"] not in eligible_states
        and any(row.get(field, "") for field in noneligible_derived_fields)
    ]
    if polluted_candidates:
        errors.append(f"noneligible candidate derived-field pollution={len(polluted_candidates)}")
    polluted_classifications = [
        row for row in classifications if row["data_status"] not in eligible_states
        and (
            row["subject_type"] == "COMPANY_DIRECT_BUSINESS"
            or bool(row["evidence_fact_id"])
            or row["source_doc_id"].startswith("S-QUAL-")
            or bool(row["chain_node_id"])
        )
    ]
    if polluted_classifications:
        errors.append(f"noneligible classification pollution={len(polluted_classifications)}")
    for qid in REJECTED_ROLE_DECISIONS:
        role = next(row for row in role_rows if row["qualification_row_id"] == qid)
        candidate = candidate_by_pair[(role["company_id"], role["track_code"])]
        if candidate["candidate_state"] != "HELD_BY_EVIDENCE_GAP" or any(
            candidate.get(field, "") for field in noneligible_derived_fields
        ):
            errors.append(f"rejected role candidate not clean: {qid}")
    selected_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for candidate in candidates:
        if candidate["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}:
            selected_groups[(candidate["track_code"], candidate["selection_bucket"])].append(candidate)
    if len(selected_groups) != 16 or any(
        sorted(row["candidate_state"] for row in rows) != ["INCLUDED_T1", "INCLUDED_T2"]
        for rows in selected_groups.values()
    ):
        errors.append("16-bucket T1/T2 coverage mismatch")
    if source_ids != conversion_ids or len(source_rows) != 786 or len(conversion_rows) != 786:
        errors.append(f"source/conversion exact set mismatch {len(source_rows)}/{len(conversion_rows)}")
    if any(row["direct_business_source_id"] not in source_ids for row in candidates if row["candidate_state"] in eligible_states):
        errors.append("eligible candidate source FK dangling")
    if any(evidence_id not in evidence_ids for row in funnel for evidence_id in split_evidence_ids(row["evidence_fact_ids"])):
        errors.append("funnel evidence FK dangling")
    if any(row["evidence_fact_id"] and row["evidence_fact_id"] not in evidence_ids for row in classifications):
        errors.append("classification evidence FK dangling")
    if any(row["evidence_fact_id"] not in evidence_ids for row in matrix_rows):
        errors.append("exposure matrix evidence FK dangling")
    if any(row["evidence_fact_id"] not in evidence_ids for row in case_map):
        errors.append("case map evidence FK dangling")
    new_qual_ids = {row["evidence_fact_id"] for row in evidence_rows if row["evidence_fact_id"].startswith("EVF-QUAL-")}
    if len(new_qual_ids) != 40 or any(not evidence_id.endswith("-R005") for evidence_id in new_qual_ids):
        errors.append(f"REPAIR005 qualification evidence exact set mismatch={len(new_qual_ids)}")
    if len(matrix_rows) != 32 or len(case_map) != 110:
        errors.append(f"matrix/map count mismatch {len(matrix_rows)}/{len(case_map)}")
    if len(funnel) != 4551:
        errors.append(f"funnel count mismatch={len(funnel)}")
    if len(prestate_rows) != 8:
        errors.append(f"direct-claim pollution prestate snapshot mismatch={len(prestate_rows)}")
    core_hashes = {
        "role_adjudication_receipt": r4.sha256_file(role_receipt_path(industry_root)),
        "candidate_ledger": r4.sha256_file(industry_root / "extracted" / "company_track_candidate_ledger.csv"),
        "qualification_funnel": r4.sha256_file(industry_root / "extracted" / "candidate_qualification_funnel.csv"),
        "classification_summary": r4.sha256_file(industry_root / "extracted" / "classification_summary.csv"),
        "evidence_fact_table": r4.sha256_file(industry_root / "evidence" / "evidence_fact_table.csv"),
        "exposure_matrix": r4.sha256_file(industry_root / "extracted" / "newenergy_company_exposure_matrix.csv"),
        "case_evidence_map": r4.sha256_file(industry_root / CASE_ID / "evidence" / "case_evidence_map.csv"),
        "pollution_prestate_snapshot": r4.sha256_file(prestate_path(industry_root)),
    }
    receipt = {
        "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
        "tool_version": TOOL_VERSION, "rule_version": ROLE_RULE_VERSION,
        "generated_at": role_rows[0]["adjudicated_at"], "review_status": REVIEW_STATUS,
        "scope": "B1_REPAIR005_ONLY_REUSE_729_PDFS_FULL_858_ROLE_STATE_REPLAY",
        "role_status_distribution": dict(sorted(role_counts.items())),
        "candidate_state_distribution": dict(sorted(candidate_counts.items())),
        "prestate_direct_claim_pollution_rows": len(prestate_rows),
        "poststate_noneligible_candidate_pollution_rows": len(polluted_candidates),
        "poststate_noneligible_classification_pollution_rows": len(polluted_classifications),
        "dangling_fk_count": sum("FK dangling" in error for error in errors),
        "selected_bucket_count": len(selected_groups), "selected_mapping_count": len(matrix_rows),
        "source_count": len(source_rows), "conversion_count": len(conversion_rows),
        "evidence_fact_count": len(evidence_rows), "case_map_count": len(case_map),
        "core_hashes": core_hashes, "validation_errors": errors,
        "validation_status": "PASS" if not errors else "FAIL",
    }
    path = validation_path(industry_root)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(receipt, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
    if errors:
        raise RuntimeError("REPAIR005 validation failed: " + "; ".join(errors))
    return receipt
def validate_artifacts(
    industry_root: Path,
    source_rows: list[dict[str, str]],
    output_rows: list[dict[str, str]],
    artifact_rows: list[dict[str, str]],
) -> dict[str, Any]:
    formal = base.formal_files(Path(__file__))
    formal_by_rel = {base.rel(path): path for path in formal}
    artifact_by_rel = {row["relative_path"]: row for row in artifact_rows}
    errors: list[str] = []
    if set(formal_by_rel) != set(artifact_by_rel):
        errors.append(
            f"artifact exact set diff missing={sorted(set(formal_by_rel) - set(artifact_by_rel))[:5]} "
            f"extra={sorted(set(artifact_by_rel) - set(formal_by_rel))[:5]}"
        )
    for relative_path, path in formal_by_rel.items():
        artifact = artifact_by_rel.get(relative_path)
        if not artifact:
            continue
        if artifact["file_size"] != str(path.stat().st_size) or artifact["sha256"] != r4.sha256_file(path):
            errors.append(f"artifact hash mismatch {relative_path}")
            break
    if any(row["tool_version"] != TOOL_VERSION for row in artifact_rows):
        errors.append("artifact tool version is not REPAIR-005")
    for row in output_rows:
        path = base.PROJECT_ROOT / row["output_path"]
        if not path.exists() or row["output_sha256"] != r4.sha256_file(path):
            errors.append(f"output hash mismatch {row['output_path']}")
            break
    if len(source_rows) != 786 or len(output_rows) != 24:
        errors.append(f"source/output count mismatch {len(source_rows)}/{len(output_rows)}")
    if errors:
        raise RuntimeError("REPAIR005 artifact validation failed: " + "; ".join(errors))
    return {
        "formal_exact_set": len(formal), "artifact_rows": len(artifact_rows),
        "output_rows": len(output_rows),
        "artifact_manifest_sha256": r4.sha256_file(industry_root / "manifest" / "artifact_manifest.csv"),
        "output_manifest_sha256": r4.sha256_file(
            industry_root / CASE_ID / "manifest" / "output_manifest.csv"
        ),
    }
def run() -> dict[str, Any]:
    project_root = Path.cwd().resolve()
    industry_root = r4.find_industry_root(project_root)
    source_rows = r4.read_csv(industry_root / "manifest" / "source_document.csv")
    conversion_rows = r4.read_csv(industry_root / "manifest" / "conversion_status.csv")
    pair_rows = r4.read_csv(industry_root / "extracted" / "candidate_page_qualification_receipt.csv")
    candidates_before = r4.read_csv(industry_root / "extracted" / "company_track_candidate_ledger.csv")
    classifications_before = r4.read_csv(industry_root / "extracted" / "classification_summary.csv")
    prestate_rows = write_prestate_snapshot_once(industry_root, candidates_before, classifications_before)
    role_rows = build_role_adjudication_receipt(industry_root, pair_rows, candidates_before)
    evidence_rows, candidates = build_evidence_and_candidates(industry_root, role_rows, source_rows)
    funnel = update_funnel(industry_root, role_rows, candidates, evidence_rows, source_rows)
    classifications, matrix_rows = update_classification_and_matrix(
        industry_root, candidates, evidence_rows, source_rows,
    )
    case_map = r4.write_company_outputs_and_map(industry_root, candidates, evidence_rows, source_rows)
    validation = validate_and_write_receipt(
        industry_root, role_rows, prestate_rows, source_rows, conversion_rows, evidence_rows,
        candidates, funnel, classifications, matrix_rows, case_map,
    )
    output_rows, artifact_rows = build_manifests(
        industry_root, source_rows, conversion_rows, evidence_rows, candidates, case_map,
    )
    artifact_validation = validate_artifacts(industry_root, source_rows, output_rows, artifact_rows)
    return {
        "status": "REPAIR005_FORMAL_PACKAGE_REBUILT_DRAFT_FOR_REVIEW",
        "role_status_distribution": validation["role_status_distribution"],
        "candidate_state_distribution": validation["candidate_state_distribution"],
        "dangling_fk_count": validation["dangling_fk_count"],
        "prestate_pollution_rows": validation["prestate_direct_claim_pollution_rows"],
        "poststate_pollution_rows": (
            validation["poststate_noneligible_candidate_pollution_rows"]
            + validation["poststate_noneligible_classification_pollution_rows"]
        ),
        "sources": len(source_rows), "conversions": len(conversion_rows),
        "evidence_facts": len(evidence_rows), "candidates": len(candidates),
        "selected": len(matrix_rows), "case_map": len(case_map),
        **artifact_validation,
        "role_receipt_sha256": r4.sha256_file(role_receipt_path(industry_root)),
        "validation_receipt_sha256": r4.sha256_file(validation_path(industry_root)),
    }
def main() -> None:
    parser = argparse.ArgumentParser()
    parser.parse_args()
    result = run()
    print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
if __name__ == "__main__":
    main()
ana-data/tools/newenergy_batch002_accept.py
New file
@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""Apply the independently approved L0 acceptance sync for NEWENERGY BATCH-002.
The script is deliberately scoped to the frozen BATCH-002 artifact set. It
updates review/package state, recalculates hashes affected by those state-only
changes, and creates acceptance receipts. It does not alter research facts,
evidence wording, candidate selection, the accepted BATCH-001 package, or the
industry-root current release.
"""
from __future__ import annotations
import csv
import hashlib
import json
import re
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-20260806-001"
CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002"
BATCH_ID = "BATCH-002"
RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002-BATCH-002-001"
FINAL_AUDIT = "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-EXECUTION-OUTPUT-REPAIR004-REREVIEW-20260807-001"
ACCEPTED = "ACCEPTED_BY_INDEPENDENT_REVIEW"
FINAL_OUTPUT = "FINAL_ACCEPTED"
FINAL_ARTIFACT = "FINAL_ACCEPTED_BY_INDEPENDENT_REVIEW"
PARENT_STATUS = "PENDING_PROJECT_ADMIN_APPEND_ONLY_TERMINAL_SYNC"
ROOT = Path(__file__).resolve().parents[2]
INDUSTRY = ROOT / "ana-data/cases/新能源案例"
CASE = INDUSTRY / CASE_ID
RESULT = ROOT / "ana-data/result/新能源案例" / CASE_ID
ARTIFACT_MANIFEST = INDUSTRY / "manifest/artifact_manifest_BATCH002.csv"
OUTPUT_MANIFEST = CASE / "manifest/output_manifest.csv"
EXECUTION_RECEIPT = CASE / "manifest/execution_validation_receipt.json"
ACCEPTANCE_RECEIPT = CASE / "manifest/acceptance_validation_receipt.json"
ACCEPTANCE_RECORD = RESULT / "acceptance_record.md"
CURRENT_STABLE_MANIFEST = INDUSTRY / "manifest/current_output_manifest.csv"
AUDITED_HASHES = {
    ROOT / "ana-data/tools/newenergy_batch002_build.py": "537C8826CE2F49DEFA188E1E308643E816E5D3B3C8E34A6BCB1B02883FB5D389",
    INDUSTRY / "manifest/legacy_to_v1_adapter_contract_BATCH002.csv": "57F232EB9FF61893FE6D6130684A0BED01E9D2B6B6E76719691A9E5523DAB954",
    INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv": "A332F0C8F11AF8DDA9E5D77D389EF34502D2ABDB9D4B3663F6CA36D8A2D98055",
    INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv": "A25D32490C7DEFA14024B1FA46C6DA3CDE323A5CDBEF36D3C84D1DF6ECA2C45D",
    INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv": "D656AB6C5B5AF4FE2632FAC8F54C6239C4894D5DA81D150588FE67A3B52DAFAE",
    ARTIFACT_MANIFEST: "A3D9933AFBB4D2066290ACD5679A9AACE986985148BB2668FA430E38871C2B25",
    OUTPUT_MANIFEST: "B074F15BFF21EB7B3872369B26A0E20E245782BB8C852BAA2D2A0994660174DE",
    CASE / "evidence/case_evidence_map.csv": "27666694FA5A38E29AB518C19A970E20F5D7CE091DCAD15C0EB29CFA9CC7EF70",
    EXECUTION_RECEIPT: "41D663B73702CFAAD0C22AF59514062484EFB22FA3AE59DB61CDBE11373F2299",
}
IMMUTABLE_B001 = {
    INDUSTRY / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/manifest/output_manifest.csv": "D0DC978D558CA56C9D32B9DC0F76489EDB0F643DB1B482585EF2D96DEE3DF6C4",
    ROOT / "ana-data/result/新能源案例/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/acceptance_record.md": "448E67471C53E1E59011A1ADBB44D2ED0BDA0068084D843251E2CCCD4A407CFA",
    CURRENT_STABLE_MANIFEST: "A2BEAF2CD0D3955ACF9DE8C6769D4BC9A3A7A1094FDD28C8447B66FD09372EB7",
}
def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest().upper()
def project_relative(path: Path) -> str:
    return path.resolve().relative_to(ROOT.resolve()).as_posix()
def read_csv(path: Path) -> tuple[list[str], list[dict[str, str]]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        return list(reader.fieldnames or []), list(reader)
def write_csv(path: Path, headers: list[str], rows: list[dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=headers, extrasaction="ignore", lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)
def write_json(path: Path, value: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8", newline="\n")
def accepted_at() -> str:
    if ACCEPTANCE_RECORD.exists():
        match = re.search(r"^- `accepted_at=([^`]+)`$", ACCEPTANCE_RECORD.read_text(encoding="utf-8"), flags=re.M)
        if match:
            return match.group(1)
    return datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()
def audit_gate() -> None:
    report = (ROOT / "ana-doc/新能源案例/案例审计报告.md").read_text(encoding="utf-8")
    marker = f"## 2026-08-07 `{FINAL_AUDIT}`"
    start = report.find(marker)
    if start < 0:
        raise RuntimeError("final audit section missing")
    tail = report[start:]
    end = tail.find("\n## ", 1)
    section = tail if end < 0 else tail[:end]
    required = (
        "总体结论:`PASS / REPAIR004 聚焦复审通过`",
        "允许分析员按项目规范把本批 outputs、canonical shard、manifest 和 result 入口",
    )
    if any(token not in section for token in required):
        raise RuntimeError("final audit does not release the acceptance sync")
def preflight() -> tuple[list[str], list[dict[str, str]]]:
    audit_gate()
    for path, expected in {**AUDITED_HASHES, **IMMUTABLE_B001}.items():
        if not path.is_file() or sha256(path) != expected:
            raise RuntimeError(f"preflight hash drift: {project_relative(path)}")
    headers, artifacts = read_csv(ARTIFACT_MANIFEST)
    if len(artifacts) != 83 or len({row["relative_path"] for row in artifacts}) != 83:
        raise RuntimeError("audited artifact exact-set is not 83 unique paths")
    for row in artifacts:
        path = ROOT / row["relative_path"]
        if not path.is_file() or path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"].upper():
            raise RuntimeError(f"audited artifact mismatch: {row['relative_path']}")
    stable_rows = read_csv(CURRENT_STABLE_MANIFEST)[1]
    if any("BATCH-002" in row.get("source_path", "") or CASE_ID in row.get("source_path", "") for row in stable_rows):
        raise RuntimeError("BATCH-002 already entered current industry release")
    return headers, artifacts
def replace_required(path: Path, old: str, new: str) -> int:
    text = path.read_text(encoding="utf-8")
    count = text.count(old)
    if count:
        path.write_text(text.replace(old, new), encoding="utf-8", newline="\n")
    elif new not in text:
        raise RuntimeError(f"required marker missing: {project_relative(path)} :: {old}")
    return count
def sync_markdown(artifacts: list[dict[str, str]]) -> dict[str, int]:
    md_paths = [ROOT / row["relative_path"] for row in artifacts if row["file_ext"] == ".md"]
    if len(md_paths) != 42:
        raise RuntimeError(f"audited markdown count changed: {len(md_paths)}/42")
    counts = {"markdown_files": len(md_paths), "review_markers": 0, "terminal_sentences": 0}
    for path in md_paths:
        counts["review_markers"] += replace_required(
            path, "- review_status: `DRAFT_FOR_REVIEW`", f"- review_status: `{ACCEPTED}`"
        )
    terminal = {
        CASE / "outputs/summary.md": (
            "- 输出状态仍为 `DRAFT_FOR_REVIEW`,等待独立执行/输出审核。",
            f"- 本批执行与输出已由独立审核 `{FINAL_AUDIT}` 以 `PASS / 0 / 0` 接受;接受不扩展研究边界或完整覆盖声明。",
        ),
        CASE / "outputs/新能源报告索引.md": (
            "当前全部产物为 `DRAFT_FOR_REVIEW`,独立执行/输出审核通过前不得标记完成或对外交付。",
            f"当前全部产物已由独立审核 `{FINAL_AUDIT}` 接受;本入口保留逐批审计血缘,行业默认阅读入口仍由行业根原子 promotion 单独控制。",
        ),
        RESULT / "result_index.md": (
            "当前状态:`DRAFT_FOR_REVIEW`;独立执行/输出审核通过前不构成正式交付。",
            f"当前状态:`{ACCEPTED}`;接受审计:`{FINAL_AUDIT}=PASS`。本逐批入口仅保留审计血缘,不替代行业根当前成果入口。",
        ),
        CASE / "manifest/batch_summary.md": (
            "执行/输出独立审核通过前不得回写父级终态或对外交付。",
            f"执行/输出独立审核 `{FINAL_AUDIT}` 已通过;父级 append-only 终态由项目管理员同步,行业根发布仍走独立 candidate/promotion。",
        ),
    }
    for path, (old, new) in terminal.items():
        counts["terminal_sentences"] += replace_required(path, old, new)
    package = CASE / "manifest/package_exact_set_receipt.md"
    counts["package_count"] = replace_required(
        package, "- expected_artifact_exact_set_count: `83`", "- expected_artifact_exact_set_count: `85`"
    )
    counts["package_audit"] = replace_required(
        package,
        "exact-set 包括本批新行业 shard、新 raw/converted、supplement 查询回执、案例 outputs/manifest/evidence、result 入口和获批 replay tool;不包括空 img/tmp 目录,也不复制前批 artifacts。",
        f"exact-set 包括本批新行业 shard、新 raw/converted、supplement 查询回执、案例 outputs/manifest/evidence、result 入口、接受记录、接受验证回执和获批 replay tool;独立接受审计为 `{FINAL_AUDIT}=PASS`,不包括空 img/tmp 目录,也不复制前批 artifacts。",
    )
    leftovers = [(project_relative(path), path.read_text(encoding="utf-8").count("DRAFT_FOR_REVIEW")) for path in md_paths]
    leftovers = [item for item in leftovers if item[1]]
    if leftovers:
        raise RuntimeError(f"draft markdown markers remain: {leftovers[:3]}")
    return counts
def sync_json_review_status(value: Any) -> Any:
    if isinstance(value, dict):
        return {
            key: ACCEPTED if key == "review_status" and item == "DRAFT_FOR_REVIEW" else sync_json_review_status(item)
            for key, item in value.items()
        }
    if isinstance(value, list):
        return [sync_json_review_status(item) for item in value]
    return value
def sync_structured_statuses(artifacts: list[dict[str, str]]) -> dict[str, int]:
    counts = {"csv_files": 0, "csv_rows": 0, "json_files": 0, "review_rows": 0, "sample_rows": 0}
    for artifact in artifacts:
        path = ROOT / artifact["relative_path"]
        if path == ARTIFACT_MANIFEST:
            continue
        if artifact["file_ext"] == ".csv":
            headers, rows = read_csv(path)
            changed = False
            for row in rows:
                if "review_status" in headers and row.get("review_status") == "DRAFT_FOR_REVIEW":
                    row["review_status"] = ACCEPTED
                    counts["review_rows"] += 1
                    changed = True
                if "sample_status" in headers and row.get("sample_status") == "READY_FOR_INDEPENDENT_REVIEW":
                    row["sample_status"] = ACCEPTED
                    counts["sample_rows"] += 1
                    changed = True
            if changed:
                write_csv(path, headers, rows)
                counts["csv_files"] += 1
                counts["csv_rows"] += len(rows)
        elif artifact["file_ext"] == ".json":
            value = json.loads(path.read_text(encoding="utf-8"))
            updated = sync_json_review_status(value)
            if updated != value:
                write_json(path, updated)
                counts["json_files"] += 1
    return counts
def update_output_manifest() -> list[dict[str, str]]:
    headers, rows = read_csv(OUTPUT_MANIFEST)
    if len(rows) != 42:
        raise RuntimeError("output manifest is not 42 rows")
    applicable = 0
    not_applicable = 0
    for row in rows:
        row["review_status"] = ACCEPTED
        if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
            output = ROOT / row["output_path"]
            if not output.is_file():
                raise FileNotFoundError(output)
            row["file_size"] = str(output.stat().st_size)
            row["sha256"] = sha256(output)
            row["output_status"] = FINAL_OUTPUT
            applicable += 1
        elif row["output_status"] == "NOT_APPLICABLE":
            not_applicable += 1
        else:
            raise RuntimeError(f"unexpected output applicability/status: {row['output_item_id']}")
    if (applicable, not_applicable) != (40, 2):
        raise RuntimeError(f"output distribution changed: {applicable}/{not_applicable}")
    write_csv(OUTPUT_MANIFEST, headers, rows)
    return rows
def update_projection_receipts() -> None:
    adapter = INDUSTRY / "manifest/legacy_to_v1_adapter_contract_BATCH002.csv"
    period = INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv"
    projection = INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv"
    headers, rows = read_csv(projection)
    for row in rows:
        shard = ROOT / row["batch002_shard_path"]
        row["batch002_shard_sha256"] = sha256(shard)
        row["adapter_contract_sha256"] = sha256(adapter)
        if row.get("period_projection_receipt_path") not in ("", "NOT_APPLICABLE"):
            row["period_projection_receipt_sha256"] = sha256(period)
        row["review_status"] = ACCEPTED
    write_csv(projection, headers, rows)
def update_registry() -> None:
    path = INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv"
    headers, rows = read_csv(path)
    for row in rows:
        shard = ROOT / row["shard_path"]
        actual_rows = read_csv(shard)[1]
        row["row_count"] = str(len(actual_rows))
        row["sha256"] = sha256(shard)
        row["review_status"] = ACCEPTED
        if row["batch_id"] == BATCH_ID:
            row["immutable"] = "YES_ACCEPTED_IMMUTABLE"
    write_csv(path, headers, rows)
def update_execution_receipt(at: str) -> None:
    receipt = json.loads(EXECUTION_RECEIPT.read_text(encoding="utf-8"))
    receipt["outputs_status"] = FINAL_OUTPUT
    receipt["review_status"] = ACCEPTED
    receipt["final_audit"] = FINAL_AUDIT
    receipt["acceptance_transition"] = "L0_STATUS_AND_AFFECTED_HASH_SYNC_ONLY"
    receipt["accepted_at"] = at
    receipt["parent_terminal_status"] = PARENT_STATUS
    receipt["legacy_adapter_contract_sha256"] = sha256(INDUSTRY / "manifest/legacy_to_v1_adapter_contract_BATCH002.csv")
    receipt["legacy_period_projection_receipt_sha256"] = sha256(INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv")
    receipt["legacy_projection_validation_sha256"] = sha256(INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv")
    write_json(EXECUTION_RECEIPT, receipt)
def write_acceptance_record(at: str, output_rows: list[dict[str, str]]) -> None:
    text = f"""# {CASE_ID} 正式接受记录
- `task_id={TASK_ID}`
- `case_id={CASE_ID}`
- `batch_id={BATCH_ID}`
- `run_id={RUN_ID}`
- `status={ACCEPTED}`
- `accepted_at={at}`
- `final_audit={FINAL_AUDIT}`
- `audit_result=PASS/0/0`
- `parent_status={PARENT_STATUS}`
- `accepted_output_manifest=ana-data/cases/新能源案例/{CASE_ID}/manifest/output_manifest.csv`
- `accepted_output_manifest_sha256={sha256(OUTPUT_MANIFEST)}`
- `applicable_outputs={sum(row['output_status'] == FINAL_OUTPUT for row in output_rows)}`
- `conditional_not_applicable_outputs={sum(row['output_status'] == 'NOT_APPLICABLE' for row in output_rows)}`
## 接受范围
本记录接受本 case/BATCH/run 的 16 个新能源子行业深化节点、32 份节点正文、三类顶层视图、9 条增量公司—赛道映射、六组 legacy-to-V1 只读投影、期间语义回执和 40 份实物输出。7 个证据不足节点继续保留 GAP,2 个条件逻辑数据集继续 `NOT_APPLICABLE`。
独立复审 `{FINAL_AUDIT}` 已给出 `PASS / blocker 0 / non-blocking 0`。审核后只执行状态与受影响 hash 的 L0 同步;没有改变来源、证据原文、候选选择、结论强度、BATCH-001 接受包或行业根当前 release。
## 边界
本接受不放行下一批、完整覆盖、估值、行情、交易、收益、市场反向补漏、核电敏感内容或数据库写入。逐批 result 入口只保留审计血缘;若要进入行业默认阅读入口,必须另建行业根 candidate,并通过既有新能源审核与原子 promotion。
"""
    ACCEPTANCE_RECORD.parent.mkdir(parents=True, exist_ok=True)
    ACCEPTANCE_RECORD.write_text(text, encoding="utf-8", newline="\n")
def write_acceptance_validation(at: str, output_rows: list[dict[str, str]], sync_counts: dict[str, Any]) -> None:
    validation = {
        "schema_version": "NEWENERGY_BATCH002_ACCEPTANCE_VALIDATION_V1",
        "task_id": TASK_ID,
        "case_id": CASE_ID,
        "batch_id": BATCH_ID,
        "run_id": RUN_ID,
        "status": ACCEPTED,
        "accepted_at": at,
        "final_audit": FINAL_AUDIT,
        "transition_type": "L0_REVIEW_STATUS_AND_AFFECTED_HASH_SYNC_ONLY",
        "outputs": {
            "manifest_rows": len(output_rows),
            "final_accepted": sum(row["output_status"] == FINAL_OUTPUT for row in output_rows),
            "not_applicable": sum(row["output_status"] == "NOT_APPLICABLE" for row in output_rows),
            "manifest_sha256": sha256(OUTPUT_MANIFEST),
        },
        "accepted_scope": {"subindustry_nodes": 16, "node_documents": 32, "incremental_company_track_mappings": 9, "gap_nodes": 7},
        "sync_counts": sync_counts,
        "core_hashes": {
            "case_evidence_map": sha256(CASE / "evidence/case_evidence_map.csv"),
            "canonical_registry": sha256(INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv"),
            "projection_validation": sha256(INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv"),
            "period_projection_receipt": sha256(INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv"),
            "execution_validation": sha256(EXECUTION_RECEIPT),
            "acceptance_record": sha256(ACCEPTANCE_RECORD),
        },
        "immutable_b001_hash_match": len(IMMUTABLE_B001),
        "industry_current_release_changed": False,
        "parent_terminal_status": PARENT_STATUS,
        "validation_status": "PASS",
    }
    write_json(ACCEPTANCE_RECEIPT, validation)
def rebuild_artifact_manifest(headers: list[str], old_rows: list[dict[str, str]], at: str) -> list[dict[str, str]]:
    by_path = {row["relative_path"]: deepcopy(row) for row in old_rows}
    additions = [ACCEPTANCE_RECEIPT, ACCEPTANCE_RECORD]
    for index, path in enumerate(additions, start=84):
        relative = project_relative(path)
        by_path[relative] = {
            "artifact_id": f"NEB2-ART-{index:04d}",
            "task_id": TASK_ID,
            "case_id": CASE_ID,
            "batch_id": BATCH_ID,
            "run_id": RUN_ID,
            "artifact_type": "RECEIPT_OR_VALIDATION" if path.suffix == ".json" else "HUMAN_READABLE_OUTPUT_OR_RECEIPT",
            "industry_case": "新能源案例",
            "industry_id": "IND-NEWENERGY",
            "subindustry_id": "",
            "company_id": "",
            "logical_path": relative,
            "relative_path": relative,
            "absolute_path": str(path.resolve()),
            "file_name": path.name,
            "file_ext": path.suffix.lower(),
            "file_size": "",
            "sha256": "",
            "source_doc_id": "",
            "source_url": "",
            "source_collected_at": "",
            "raw_pool_path": "",
            "source_file_name": "",
            "detected_type": path.suffix.lower().lstrip("."),
            "archive_file_name": path.name,
            "extension_added_by_archive_flag": "NO",
            "extension_mismatch_flag": "NO",
            "created_at": at,
            "created_by": "case_analysis.analyst.new_energy",
            "tool_or_method": "newenergy_batch002_accept.py",
            "tool_version": "BATCH002-ACCEPT-V1",
            "parameters_summary": "independent PASS terminal L0 status/hash sync only",
            "source_snapshot_id": "BATCH002_ACCEPTANCE",
            "artifact_status": FINAL_ARTIFACT,
            "sensitivity_screen": "LEGAL_PUBLIC_CIVIL_NUCLEAR_HIGH_LEVEL_ONLY",
            "schema_version": "NEWENERGY_EXTENSION_V1",
            "review_status": ACCEPTED,
        }
    if len(by_path) != 85:
        raise RuntimeError(f"post-acceptance artifact exact-set is not 85: {len(by_path)}")
    rows: list[dict[str, str]] = []
    for relative in sorted(by_path):
        row = by_path[relative]
        path = ROOT / relative
        if not path.is_file():
            raise FileNotFoundError(path)
        row["file_size"] = str(path.stat().st_size)
        row["sha256"] = sha256(path)
        row["artifact_status"] = FINAL_ARTIFACT
        row["review_status"] = ACCEPTED
        if row.get("parameters_summary") == "exact released batch/run; immutable B001 references; DRAFT outputs":
            row["parameters_summary"] = "exact released batch/run; immutable B001 references; independently accepted outputs"
        rows.append(row)
    write_csv(ARTIFACT_MANIFEST, headers, rows)
    return rows
def verify_accepted() -> dict[str, Any]:
    for path, expected in IMMUTABLE_B001.items():
        if sha256(path) != expected:
            raise RuntimeError(f"immutable BATCH-001/current release drift: {project_relative(path)}")
    output_rows = read_csv(OUTPUT_MANIFEST)[1]
    if len(output_rows) != 42:
        raise RuntimeError("accepted output manifest row count mismatch")
    if sum(row["output_status"] == FINAL_OUTPUT for row in output_rows) != 40:
        raise RuntimeError("accepted applicable output count mismatch")
    if sum(row["output_status"] == "NOT_APPLICABLE" for row in output_rows) != 2:
        raise RuntimeError("accepted N/A output count mismatch")
    if any(row["review_status"] != ACCEPTED for row in output_rows):
        raise RuntimeError("output review status mismatch")
    for row in output_rows:
        if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
            path = ROOT / row["output_path"]
            if path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"].upper():
                raise RuntimeError(f"accepted output hash mismatch: {row['output_path']}")
    artifact_rows = read_csv(ARTIFACT_MANIFEST)[1]
    if len(artifact_rows) != 85 or len({row["relative_path"] for row in artifact_rows}) != 85:
        raise RuntimeError("accepted artifact exact-set mismatch")
    for row in artifact_rows:
        path = ROOT / row["relative_path"]
        if not path.is_file() or path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"].upper():
            raise RuntimeError(f"accepted artifact hash mismatch: {row['relative_path']}")
        if row["artifact_status"] != FINAL_ARTIFACT or row["review_status"] != ACCEPTED:
            raise RuntimeError(f"accepted artifact status mismatch: {row['relative_path']}")
    stable_rows = read_csv(CURRENT_STABLE_MANIFEST)[1]
    batch2_current = sum("BATCH-002" in row.get("source_path", "") or CASE_ID in row.get("source_path", "") for row in stable_rows)
    if batch2_current:
        raise RuntimeError("BATCH-002 polluted the current BATCH-001 industry release")
    if not ACCEPTANCE_RECORD.is_file() or not ACCEPTANCE_RECEIPT.is_file():
        raise RuntimeError("acceptance records missing")
    return {
        "status": ACCEPTED,
        "final_audit": FINAL_AUDIT,
        "output_manifest_rows": len(output_rows),
        "final_accepted_outputs": 40,
        "not_applicable_outputs": 2,
        "artifact_exact_set": len(artifact_rows),
        "output_manifest_sha256": sha256(OUTPUT_MANIFEST),
        "artifact_manifest_sha256": sha256(ARTIFACT_MANIFEST),
        "acceptance_record_sha256": sha256(ACCEPTANCE_RECORD),
        "acceptance_validation_sha256": sha256(ACCEPTANCE_RECEIPT),
        "immutable_b001_and_current_release_hash_match": len(IMMUTABLE_B001),
        "batch002_current_release_paths": batch2_current,
        "parent_status": PARENT_STATUS,
    }
def run() -> dict[str, Any]:
    if ACCEPTANCE_RECORD.exists():
        result = verify_accepted()
        result["idempotent"] = True
        return result
    headers, artifacts = preflight()
    at = accepted_at()
    markdown_counts = sync_markdown(artifacts)
    structured_counts = sync_structured_statuses(artifacts)
    output_rows = update_output_manifest()
    update_projection_receipts()
    update_registry()
    update_execution_receipt(at)
    write_acceptance_record(at, output_rows)
    write_acceptance_validation(at, output_rows, {"markdown": markdown_counts, "structured": structured_counts})
    rebuild_artifact_manifest(headers, artifacts, at)
    result = verify_accepted()
    result["idempotent"] = False
    result["accepted_at"] = at
    return result
if __name__ == "__main__":
    print(json.dumps(run(), ensure_ascii=False, sort_keys=True, indent=2))
ana-data/tools/newenergy_batch002_build.py
New file
@@ -0,0 +1,2210 @@
from __future__ import annotations
import csv
import hashlib
import json
import re
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable
ROOT = Path(__file__).resolve().parents[2]
INDUSTRY = ROOT / "ana-data" / "cases" / "新能源案例"
CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002"
TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-20260806-001"
BATCH_ID = "BATCH-002"
RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002-BATCH-002-001"
CASE = INDUSTRY / CASE_ID
RESULT = ROOT / "ana-data" / "result" / "新能源案例" / CASE_ID
IMG = ROOT / "ana-data" / "img" / "新能源案例" / CASE_ID
TMP = ROOT / "ana-data" / "tmp" / "新能源案例" / CASE_ID / RUN_ID
SCHEMA = "NEWENERGY_EXTENSION_V1"
REVIEW = "DRAFT_FOR_REVIEW"
SOURCE_CUTOFF = "2026-08-06T20:27:54+08:00"
EXECUTED_AT = "2026-08-07T00:30:00+08:00"
REPAIR_ID = "EXECUTION_OUTPUT_REPAIR001"
LATEST_REPAIR_ID = "EXECUTION_OUTPUT_REPAIR004"
QUERY_PROBE_PATH = INDUSTRY / "supplement" / "NEB2_external_public_query_probe_REPAIR001.json"
QUERY_RECEIPT_PATH = INDUSTRY / "supplement" / "NEB2_external_public_query_receipt_BATCH002.csv"
def rel(path: Path) -> str:
    return path.relative_to(ROOT).as_posix()
def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest().upper()
def read_csv(path: Path) -> list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as fh:
        return list(csv.DictReader(fh))
def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str] | None = None) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    if fields is None:
        if not rows:
            raise ValueError(f"refuse empty csv without schema: {path}")
        fields = list(rows[0].keys())
    with path.open("w", encoding="utf-8-sig", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)
def write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text.rstrip() + "\n", encoding="utf-8", newline="\n")
def write_json(path: Path, obj: Any) -> None:
    write_text(path, json.dumps(obj, ensure_ascii=False, indent=2, sort_keys=True))
def index_unique(rows: Iterable[dict[str, str]], key: str) -> dict[str, dict[str, str]]:
    out: dict[str, dict[str, str]] = {}
    for row in rows:
        value = row[key]
        if value in out:
            raise ValueError(f"duplicate {key}: {value}")
        out[value] = row
    return out
BASELINE_HASHES = {
    INDUSTRY / "extracted" / "company_track_candidate_ledger.csv": "ABD3D0BF2CEF2D9D0225F2D37A9542A0899B75B127E829747C767FAED03E54E5",
    INDUSTRY / "extracted" / "candidate_qualification_funnel.csv": "8FCC1C9563A3709E04EE747173A55DF76135B760E186529DC1F1DA8A07C0891A",
    INDUSTRY / "extracted" / "candidate_role_adjudication_receipt.csv": "7E47068068E47EDDDF9B9B6766D5A8FBDF40CEB62FFF33508DBF65302F42D824",
    INDUSTRY / "evidence" / "evidence_fact_table.csv": "B9FE3822781F776E93A57309E52794EF445B86820B96410B486E57A852540AC9",
    INDUSTRY / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "evidence" / "case_evidence_map.csv": "CE6CFFB10BD3B11FD4E29B5381B814DAC66A8EE1E53BB9821805312D19AFD103",
    INDUSTRY / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "manifest" / "output_manifest.csv": "D0DC978D558CA56C9D32B9DC0F76489EDB0F643DB1B482585EF2D96DEE3DF6C4",
    ROOT / "ana-data" / "result" / "新能源案例" / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "acceptance_record.md": "448E67471C53E1E59011A1ADBB44D2ED0BDA0068084D843251E2CCCD4A407CFA",
}
for baseline_path, expected in BASELINE_HASHES.items():
    if not baseline_path.exists():
        raise FileNotFoundError(f"missing immutable baseline: {baseline_path}")
    actual = sha256(baseline_path)
    if actual != expected:
        raise RuntimeError(f"immutable baseline drift: {rel(baseline_path)} {actual} != {expected}")
for directory in (
    CASE / "outputs" / "核心文档" / "子行业深化",
    CASE / "manifest",
    CASE / "evidence",
    RESULT,
    IMG,
    TMP,
    INDUSTRY / "supplement",
):
    directory.mkdir(parents=True, exist_ok=True)
for forbidden in (CASE / "raw", CASE / "converted", CASE / "extracted", CASE / "supplement"):
    if forbidden.exists():
        raise RuntimeError(f"case storage split violated: {forbidden}")
ledger = read_csv(INDUSTRY / "extracted" / "company_track_candidate_ledger.csv")
funnel = read_csv(INDUSTRY / "extracted" / "candidate_qualification_funnel.csv")
roles = read_csv(INDUSTRY / "extracted" / "candidate_role_adjudication_receipt.csv")
evidence = read_csv(INDUSTRY / "evidence" / "evidence_fact_table.csv")
source_docs = read_csv(INDUSTRY / "manifest" / "source_document.csv")
conversions = read_csv(INDUSTRY / "manifest" / "conversion_status.csv")
artifacts_b1 = read_csv(INDUSTRY / "manifest" / "artifact_manifest.csv")
evidence_by_id = index_unique(evidence, "evidence_fact_id")
source_by_id = index_unique(source_docs, "doc_id")
conversion_by_source = {r["source_doc_id"]: r for r in conversions}
artifact_by_rel = {r["relative_path"]: r for r in artifacts_b1}
funnel_by_pair = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in funnel}
role_by_pair = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in roles}
ledger_by_pair = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in ledger}
NODES = [
    {
        "n": 1, "track": "BATTERY", "code": "BAT_RESOURCE_MATERIAL", "name": "资源与主材", "bucket": "资源与主材", "folder": "01_资源与主材",
        "boundary": "纳入锂资源、锂盐、正极/负极、电解液和隔膜的直接生产经营;排除一般化工、采购方、客户和题材关联。",
        "role": "上游资源与锂盐连接电芯材料成本,四大主材分别承担离子载体、电极反应、离子传导与安全隔离功能。",
        "route": "锂盐—正负极—电解液—隔膜分层;不同材料、规格与应用场景不得合并为单一价格或产能口径。",
        "market_evf": "EVF-MKT-BAT-03", "risk": "资源品价格和材料路线变化可能改变成本传导;规格、期间或税口径不同会使价格比较失真。",
    },
    {
        "n": 2, "track": "BATTERY", "code": "BAT_CELL", "name": "电芯制造", "bucket": "电芯制造", "folder": "02_电芯制造",
        "boundary": "纳入动力或储能电芯的研发、生产和销售;模组、Pack、整车和单纯电池材料分别分账。",
        "role": "电芯把材料体系转化为可用电化学单元,是动力与储能系统的核心制造环节。",
        "route": "动力与储能应用分账;磷酸铁锂、三元等路线按应用、安全、寿命与成本约束比较。",
        "market_evf": "EVF-MKT-BAT-01", "risk": "产量增长不等于终端消化,库存、利用率、质量与安全信息仍需单独验证。",
    },
    {
        "n": 3, "track": "BATTERY", "code": "BAT_SYSTEM_COMPONENT", "name": "系统/部件/BMS-Pack", "bucket": "系统/部件/BMS-Pack", "folder": "03_系统部件BMS-Pack",
        "boundary": "纳入电芯模组、Pack、BMS、结构件和系统集成的直接业务;排除仅使用电池的终端产品。",
        "role": "系统环节完成电芯成组、状态监测、热安全和应用适配,动力与储能的设计目标不可混用。",
        "route": "从电芯到模组、Pack、BMS和系统集成逐层识别;分部不可拆时降低暴露结论强度。",
        "market_evf": "EVF-MKT-BAT-02", "risk": "价值量受集成边界、外购比例和应用结构影响,不能由电池总量直接推导公司收入。",
    },
    {
        "n": 4, "track": "BATTERY", "code": "BAT_EQUIPMENT_RECYCLE", "name": "设备与回收循环", "bucket": "设备与回收循环", "folder": "04_设备与回收循环",
        "boundary": "纳入锂电前中后段专用设备、检测设备和电池回收处理的直接业务;通用施工、包装和自用项目不纳入。",
        "role": "设备支撑制造工序,回收环节处理退役电池与生产废料;两者的订单、产能和经济性口径必须分开。",
        "route": "设备按工序验证,回收按来源料、处理工艺与再生产品验证;仅有‘新能源’字样不足以确认角色。",
        "market_evf": "EVF-MKT-BAT-01", "risk": "本批冻结候选池未找到可升级的直接主源,相关企业增量保持缺口。",
    },
    {
        "n": 5, "track": "SOLAR", "code": "SOL_SILICON_WAFER", "name": "硅料/硅片与材料", "bucket": "硅料/硅片与材料", "folder": "05_硅料硅片与材料",
        "boundary": "纳入多晶硅、拉晶、硅棒/硅片和直接关键材料;电池片、组件和电站业务另列。",
        "role": "上游材料决定电池片的基础晶硅供给,产能必须区分名义、有效、在产与不同尺寸/导电类型。",
        "route": "硅料—拉晶—切片分层,N/P 型和尺寸规格分账;不以总产能替代可售合格产出。",
        "market_evf": "EVF-MKT-SOL-01", "risk": "装机需求不能直接推导硅片盈利,供给、价格、库存与技术切换均可能造成背离。",
    },
    {
        "n": 6, "track": "SOLAR", "code": "SOL_CELL_MODULE", "name": "电池片/组件", "bucket": "电池片/组件", "folder": "06_电池片组件",
        "boundary": "纳入太阳能电池片和组件的直接制造销售;玻璃等辅材、逆变器与电站运营分别归桶。",
        "role": "电池片完成光电转换,组件完成封装和终端产品交付;技术效率、良率和出货口径需区分。",
        "route": "电池技术与组件产品分账,产能、产量、出货、并网装机不能互相替代。",
        "market_evf": "EVF-MKT-SOL-01", "risk": "快速路线迭代和供给扩张可能造成存货、减值与旧产线利用率风险。",
    },
    {
        "n": 7, "track": "SOLAR", "code": "SOL_BALANCE_EQUIPMENT", "name": "设备/辅材/逆变器", "bucket": "设备/辅材/逆变器", "folder": "07_光伏设备辅材逆变器",
        "boundary": "纳入光伏专用设备、辅材和逆变器直接业务;通用工业品潜在用途和项目采购不纳入。",
        "role": "设备决定制造能力,辅材影响封装可靠性,逆变器承担直交流转换与系统控制,三者分别核验。",
        "route": "按设备、玻璃/胶膜等辅材、逆变器三个子层记录,不用组件需求替代各自产品证据。",
        "market_evf": "EVF-MKT-SOL-01", "risk": "需求联动存在时滞,设备订单、辅材出货与逆变器销售不能由装机量机械外推。",
    },
    {
        "n": 8, "track": "SOLAR", "code": "SOL_SYSTEM_STATION", "name": "系统集成/电站建设运营", "bucket": "系统集成/电站建设运营", "folder": "08_光伏系统电站",
        "boundary": "纳入光伏 EPC、开发、建设、持有运营与发电收入;设备采购和内部自用电站不等于对外系统业务。",
        "role": "项目从开发、建设、并网到持有运营分阶段,工程收入和发电收入必须分账。",
        "route": "项目阶段、所有权和收入确认口径是核心;规划、在建、并网和运营不得静默合并。",
        "market_evf": "EVF-MKT-SOL-02", "risk": "装机增长不代表单个开发商收益,消纳、电价、融资和资产处置均会改变经营结果。",
    },
    {
        "n": 9, "track": "WIND", "code": "WND_COMPONENT", "name": "材料与关键零部件", "bucket": "材料与关键零部件", "folder": "09_风电材料关键零部件",
        "boundary": "纳入叶片、铸锻件、主轴、轴承、齿轮箱等直接制造;通用材料和相邻工业品不纳入。",
        "role": "关键零部件连接整机技术平台与上游制造,产品规格、尺寸和认证决定可交付范围。",
        "route": "按叶片、铸锻、传动和轴承等子层识别,海陆产品、尺寸与交付周期分账。",
        "market_evf": "EVF-MKT-WIND-01", "risk": "大型化与海上化会改变材料、工艺和验证要求,旧规格产能不可直接视为有效供给。",
    },
    {
        "n": 10, "track": "WIND", "code": "WND_TURBINE", "name": "整机", "bucket": "整机", "folder": "10_风电整机",
        "boundary": "纳入风力发电整机研发、制造、销售与随整机形成的直接服务;运营商和通用电气设备另列。",
        "role": "整机集成叶轮、传动、电气与控制系统,并承担交付、质保和部分运维责任。",
        "route": "海陆机型、平台功率、订单、交付和质保口径分账;采购或使用风机不构成整机业务。",
        "market_evf": "EVF-MKT-WIND-01", "risk": "本批空桶候选均未获得直接整机主源,不能由风电项目或发电业务反推整机制造。",
    },
    {
        "n": 11, "track": "WIND", "code": "WND_TOWER_CABLE_ENGINEERING", "name": "塔筒/海缆/工程配套", "bucket": "塔筒/海缆/工程配套", "folder": "11_塔筒海缆工程配套",
        "boundary": "纳入塔筒、基础、海缆、吊装和风电工程直接业务;一般施工、钢管或项目相邻供应不纳入。",
        "role": "工程配套把设备连接到场址和电网,塔筒、海缆、基础与吊装的制造/施工能力分别验证。",
        "route": "海陆项目、制造与施工、订单与收入确认分账;通用工程资质不能替代风电直接业务。",
        "market_evf": "EVF-MKT-WIND-01", "risk": "项目进度、海况、原材料与验收会影响交付,本批未找到可升级的增量企业。",
    },
    {
        "n": 12, "track": "WIND", "code": "WND_OPERATION_OM", "name": "项目运营与运维服务", "bucket": "项目运营与运维服务", "folder": "12_风电运营运维",
        "boundary": "纳入风电场持有运营和对外运维服务;核准、建设、并网、商运及运维收入分别记录。",
        "role": "运营环节把装机转化为发电量,利用小时、可利用率、消纳和电价共同影响经营事实。",
        "route": "项目阶段与海陆风分账,发电量和运维服务收入不互相替代。",
        "market_evf": "EVF-MKT-WIND-02", "risk": "资源条件、限电、检修和电价机制变化会造成装机与发电表现背离。",
    },
    {
        "n": 13, "track": "NUCLEAR", "code": "NUC_OPERATOR", "name": "运营商", "bucket": "运营商", "folder": "13_核电运营商",
        "boundary": "仅纳入民用核电机组持有运营及公开高层运营指标;核准、在建、并网和商运严格分账。",
        "role": "运营商承担机组建设转运营后的发电与安全运行责任,本研究只处理公开聚合指标。",
        "route": "机组台数、装机、发电量与上网电量分别记录;不处理厂址安防、控制系统或敏感参数。",
        "market_evf": "EVF-MKT-NUC-01", "risk": "本批没有新的运营商合格余额;候选中的其他发电或设备业务不能升级为核电运营。",
    },
    {
        "n": 14, "track": "NUCLEAR", "code": "NUC_ENGINEERING_EPC", "name": "工程/EPC", "bucket": "工程/EPC", "folder": "14_核电工程EPC",
        "boundary": "仅纳入民用核电工程设计、建设或总承包的直接公开业务;一般建材、防水和相邻施工不纳入核电 EPC。",
        "role": "工程环节连接核准、设计、施工和投运,合同阶段与收入确认需用正式披露核验。",
        "route": "只保留公开项目阶段和公司角色,不记录关键基础设施敏感细节。",
        "market_evf": "EVF-MKT-NUC-2024", "risk": "东方雨虹公开案例仅支持防水材料及施工服务,不能据此认定核电工程总承包。",
    },
    {
        "n": 15, "track": "NUCLEAR", "code": "NUC_NI_CI_EQUIPMENT", "name": "核岛/常规岛主设备", "bucket": "核岛/常规岛主设备", "folder": "15_核岛常规岛主设备",
        "boundary": "纳入公开披露的民用核岛/常规岛主设备直接制造交付;通用设备潜在用途和采购方不纳入。",
        "role": "主设备与机组系统直接相关,公开产品、合同、交付与收入证据是公司资格基础。",
        "route": "只到公开产品类别和项目阶段,不处理非公开性能、布置、控制或脆弱性细节。",
        "market_evf": "EVF-MKT-NUC-2024", "risk": "冻结账本在该桶没有可处理候选,本批明确保留企业增量缺口。",
    },
    {
        "n": 16, "track": "NUCLEAR", "code": "NUC_COMPONENT_MATERIAL_ICT", "name": "核级部件/材料/仪控电气", "bucket": "核级部件/材料/仪控电气", "folder": "16_核级部件材料仪控电气",
        "boundary": "纳入公开核级部件、材料、仪控和电气直接业务;一般工业产品、资质推测和潜在用途不纳入。",
        "role": "该环节依赖核级产品、认证或订单的明确公开证据,不能由公司能力或客户关系推断。",
        "route": "按部件、材料、仪控、电气分层;仅记录民用公开高层信息。",
        "market_evf": "EVF-MKT-NUC-2024", "risk": "10 个冻结候选均未形成直接核级业务主源,增量映射保持 HELD。",
    },
]
node_by_bucket = {(n["track"], n["bucket"]): n for n in NODES}
if len(node_by_bucket) != 16:
    raise RuntimeError("node bucket uniqueness failed")
COMPANY_EVF = {
    ("BATTERY", "资源与主材"): "EVF-QUAL-BATTERY-002497-R005",
    ("BATTERY", "电芯制造"): "EVF-BATTERY-300750-BUSINESS",
    ("BATTERY", "系统/部件/BMS-Pack"): "EVF-BATTERY-300207-BUSINESS",
    ("SOLAR", "硅料/硅片与材料"): "EVF-QUAL-SOLAR-601908-R005",
    ("SOLAR", "电池片/组件"): "EVF-QUAL-SOLAR-002623-R005",
    ("SOLAR", "设备/辅材/逆变器"): "EVF-SOLAR-300274-BUSINESS",
    ("SOLAR", "系统集成/电站建设运营"): "EVF-QUAL-SOLAR-301070-R005",
    ("WIND", "材料与关键零部件"): "EVF-WIND-300443-BUSINESS",
    ("WIND", "项目运营与运维服务"): "EVF-WIND-001289-BUSINESS",
}
def company_evidence_summary(row: dict[str, str], ev: dict[str, str]) -> str:
    if row["security_code"] == "002497":
        return "年报披露公司锂产品面向汽车厂商、电池企业和正极材料企业销售。"
    return ev["evidence_text"]
eligible_by_bucket: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
for row in ledger:
    if row["candidate_state"] == "ELIGIBLE_NOT_SELECTED_BATCH001":
        eligible_by_bucket[(row["track_code"], row["selection_bucket"])].append(row)
for rows in eligible_by_bucket.values():
    rows.sort(key=lambda r: (int(r["selection_rank"]), r["exchange_code"], r["security_code"]))
incremental: list[dict[str, str]] = []
for node in NODES:
    rows = eligible_by_bucket.get((node["track"], node["bucket"]), [])
    if not rows:
        continue
    prior = rows[0]
    ev_id = COMPANY_EVF[(node["track"], node["bucket"])]
    ev = evidence_by_id[ev_id]
    if ev["company_id"] != prior["company_id"]:
        raise RuntimeError(f"company evidence mismatch for {node['code']}")
    incremental.append({**prior, "node_code": node["code"], "node_name": node["name"], "evidence_fact_id": ev_id})
expected_incremental_codes = {"002497", "300750", "300207", "601908", "002623", "300274", "301070", "300443", "001289"}
if {r["security_code"] for r in incremental} != expected_incremental_codes:
    raise RuntimeError("mechanical inherited incremental set changed")
QUEUE_CODES = {
    ("BATTERY", "设备与回收循环"): ["002081", "300793", "600120", "600248", "600353", "603052", "603687", "688335", "688610", "000036"],
    ("WIND", "整机"): ["600089", "600863", "603969", "000155", "000690", "600011", "600248", "600548", "600642", "601985"],
    ("WIND", "塔筒/海缆/工程配套"): ["601618", "002443", "600268", "600985", "601669", "000862", "002307", "601069", "000933"],
    ("NUCLEAR", "运营商"): ["300198", "601121", "000958", "300165", "600312", "600550", "600651", "601609", "601618", "603308"],
    ("NUCLEAR", "工程/EPC"): ["002271"],
    ("NUCLEAR", "核岛/常规岛主设备"): [],
    ("NUCLEAR", "核级部件/材料/仪控电气"): ["600685", "601199", "601212", "603013", "603282", "603700", "603800", "688198", "000571", "000581"],
}
if set(QUEUE_CODES) != {key for key in node_by_bucket if key not in eligible_by_bucket}:
    raise RuntimeError("empty-bucket set changed")
queue_rows: list[dict[str, Any]] = []
for (track, bucket), codes in QUEUE_CODES.items():
    node = node_by_bucket[(track, bucket)]
    for rank, code in enumerate(codes, 1):
        candidates = [r for r in ledger if r["security_code"] == code and r["track_code"] == track and r["selection_bucket"] == bucket]
        if len(candidates) != 1:
            raise RuntimeError(f"queue pair unresolved: {track}/{bucket}/{code} count={len(candidates)}")
        prior = candidates[0]
        f = funnel_by_pair[(prior["company_id"], track, bucket)]
        rr = role_by_pair[(prior["company_id"], track, bucket)]
        prior_attachment_id = rr["selected_attachment_id"]
        if not prior_attachment_id:
            prior_attachment_id = (f["announcement_ids"].split(";")[0] if f["announcement_ids"] else "")
        prior_source_doc_id = f"S-QUAL-AR-{prior_attachment_id}" if prior_attachment_id else ""
        if not prior_source_doc_id or prior_source_doc_id not in source_by_id:
            raise RuntimeError(f"queue prior annual report unresolved: {track}/{bucket}/{code}/{prior_source_doc_id}")
        prior_doc = source_by_id[prior_source_doc_id]
        new_doc = "S-NEB2-YUHONG-SUSTAINABILITY-2023" if code == "002271" and track == "NUCLEAR" else ""
        new_evf = "EVF-NE-B002-ADJ-002271" if new_doc else ""
        result = "HELD_ADJACENT_WATERPROOFING_MATERIAL_AND_SERVICE_NOT_NUCLEAR_EPC" if new_doc else "HELD_NO_NEW_DIRECT_TARGET_BUCKET_PRIMARY_SOURCE"
        queue_rows.append({
            "queue_item_id": f"NEB2-QUEUE-{node['n']:02d}-{rank:02d}",
            "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "subindustry_node_code": node["code"], "track_code": track, "selection_bucket": bucket,
            "queue_rank": rank, "company_id": prior["company_id"], "security_code": code,
            "security_name": prior["security_name"], "prior_candidate_state": prior["candidate_state"],
            "prior_query_hit_count": f["query_hit_count"], "prior_business_context_result": f["business_context_rule_result"],
            "prior_source_doc_id": prior_source_doc_id,
            "prior_locator": rr["selected_page"] and f"page={rr['selected_page']}" or f["fulltext_context_locator"],
            "prior_raw_file_path": prior_doc["raw_file_path"], "prior_raw_sha256": prior_doc["file_sha256"].upper(),
            "prior_converted_text_path": prior_doc["converted_text_path"],
            "prior_retrieval_result": f["annual_report_retrieval_result"],
            "prior_full_page_search_result": f["page_level_verification_result"],
            "prior_context_sha256": f["fulltext_context_sha256"],
            "prior_context_excerpt": f["fulltext_context_excerpt_sanitized"],
            "prior_company_self_gate": rr["company_self_gate"], "prior_target_bucket_role_gate": rr["target_bucket_role_gate"],
            "prior_adjacent_or_self_use_exclusion_gate": rr["adjacent_or_self_use_exclusion_gate"],
            "prior_role_decision": rr["final_role_decision"], "prior_decision_basis_code": rr["decision_basis_code"],
            "completion_channel": "IMMUTABLE_B001_OFFICIAL_ANNUAL_REPORT_REVIEW_PLUS_BOUNDED_PUBLIC_PRIMARY_QUERY",
            "query_receipt_path": rel(INDUSTRY / "supplement" / f"NEB2_QRY_{node['code']}_evidence_completion.json"),
            "actual_query_receipt_path": rel(QUERY_RECEIPT_PATH),
            "new_source_doc_id": new_doc, "new_evidence_fact_id": new_evf,
            "company_self_gate": "PASS_NEW_ADJACENT_SERVICE_CONTEXT" if new_doc else rr["company_self_gate"],
            "target_bucket_direct_role_gate": "FAIL_ADJACENT_SERVICE" if new_doc else rr["target_bucket_role_gate"],
            "current_public_primary_source_gate": "PASS_ADJACENT_ONLY" if new_doc else "NO_NEW_DIRECT_SOURCE",
            "locator_gate": "PASS_PAGE_14" if new_doc else "NO_NEW_DIRECT_LOCATOR",
            "final_state": "HELD_BY_EVIDENCE_GAP", "verification_result": result,
            "stop_reason": "BOUNDED_ONE_ROUND_COMPLETED_NO_QUALIFIED_PAIR",
            "coverage_claim": "NONE_INCREMENTAL_EVIDENCED_POOL_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
        })
if len(queue_rows) != 50:
    raise RuntimeError(f"bounded queue expected 50 rows, got {len(queue_rows)}")
query_receipts = {
    "BAT_EQUIPMENT_RECYCLE": {
        "query": "冻结候选名称 + 锂电设备/电池回收 + 年报/公告/官网",
        "result": "未定位到可把 10 个候选升级为锂电专用设备或回收直接业务的公开主源;复用前批 2025 年报全页检索与角色裁决。",
    },
    "WND_TURBINE": {
        "query": "冻结候选名称 + 风电整机/风力发电机组 + 年报/公告/官网",
        "result": "未定位到可把 10 个候选升级为风电整机直接制造的公开主源;项目、发电或通用设备语境不升级。",
    },
    "WND_TOWER_CABLE_ENGINEERING": {
        "query": "冻结候选名称 + 风电塔筒/海缆/吊装/工程 + 年报/公告/官网",
        "result": "未定位到可把 9 个候选升级为目标桶直接业务的公开主源;一般工程、钢管和项目关联不升级。",
    },
    "NUC_OPERATOR": {
        "query": "冻结候选名称 + 民用核电运营/核电机组 + 年报/公告/官网",
        "result": "未定位到可把 10 个候选升级为核电运营商的公开主源;其他发电、设备或项目语境不升级。",
    },
    "NUC_ENGINEERING_EPC": {
        "query": "东方雨虹 + 核电 + EPC/工程 + 年报/可持续发展报告/官网",
        "result": "东方雨虹官网 2023 可持续发展报告仅支持核电机组厂房地下防水工程材料及施工服务,属于相邻专业服务,不是核电工程 EPC。",
        "official_url": "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf",
    },
    "NUC_NI_CI_EQUIPMENT": {
        "query": "冻结账本目标桶候选生成",
        "result": "前批冻结账本在完成排除后无候选,按设计不自由扩池。",
    },
    "NUC_COMPONENT_MATERIAL_ICT": {
        "query": "冻结候选名称 + 核级部件/材料/仪控/电气 + 年报/公告/官网",
        "result": "未定位到可把 10 个候选升级为核级直接业务的公开主源;一般工业产品、客户或潜在用途不升级。",
    },
}
for node in NODES:
    if (node["track"], node["bucket"]) not in QUEUE_CODES:
        continue
    receipt = {
        "query_id": f"NEB2-QRY-{node['code']}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "darkline_mode": "EXTERNAL_PUBLIC_EVIDENCE_COMPLETION",
        "subindustry_node_code": node["code"], "selection_bucket": node["bucket"],
        "candidate_codes": QUEUE_CODES[(node["track"], node["bucket"])],
        "search_window": "ONE_INITIAL_BROAD_PUBLIC_PRIMARY_QUERY_PLUS_PRIOR_ACCEPTED_FULL_PAGE_REPORT_REVIEW",
        "source_priority": ["exchange_or_statutory_disclosure", "company_official_report_or_IR", "official_project_or_regulator"],
        "search_engine_usage": "LOCATE_ORIGINAL_ONLY", "market_reverse_scan": "NOT_APPLICABLE",
        "access_control_bypass": "NO", "nuclear_sensitive_content_saved": "NO",
        "source_cutoff_at": SOURCE_CUTOFF, "executed_at": EXECUTED_AT,
        "actual_query_receipt_path": rel(QUERY_RECEIPT_PATH),
        **query_receipts[node["code"]], "review_status": REVIEW,
    }
    write_json(INDUSTRY / "supplement" / f"NEB2_QRY_{node['code']}_evidence_completion.json", receipt)
yuhong_raw = INDUSTRY / "raw" / "official_filings" / "NEB2_S-NEB2-YUHONG-SUSTAINABILITY-2023_2024-04-22_yuhong-sustainability-2023.pdf"
yuhong_txt = INDUSTRY / "converted" / "official_filings" / "NEB2_S-NEB2-YUHONG-SUSTAINABILITY-2023_yuhong-sustainability-2023.txt"
if not yuhong_raw.exists() or not yuhong_txt.exists():
    raise FileNotFoundError("Yuhong official source or conversion missing")
if not QUERY_PROBE_PATH.exists():
    raise FileNotFoundError("REPAIR001 official URL probe receipt missing; run newenergy_batch002_query_probe.py first")
probe_payload = json.loads(QUERY_PROBE_PATH.read_text(encoding="utf-8"))
if probe_payload.get("status") != "COMPLETED_WITH_ALL_RESPONSE_OR_FAILURE_STATES_RECORDED":
    raise RuntimeError("official URL probe did not close all response/failure states")
probe_by_url = {row["url"]: row for row in probe_payload["url_results"]}
QUERY_TERMS = {
    "BAT_EQUIPMENT_RECYCLE": "锂电设备|电池设备|电池回收|梯次利用|再生利用",
    "WND_TURBINE": "风电整机|风力发电机组|风机制造|整机销售",
    "WND_TOWER_CABLE_ENGINEERING": "风电塔筒|风电海缆|风电吊装|风电工程",
    "NUC_OPERATOR": "核电运营|核电机组|核电发电|商运核电",
    "NUC_ENGINEERING_EPC": "核电EPC|核电工程总承包|核电工程设计建设",
    "NUC_COMPONENT_MATERIAL_ICT": "核级部件|核级材料|核电仪控|核电电气",
}
actual_query_rows: list[dict[str, Any]] = []
for row in queue_rows:
    source_id = row["new_source_doc_id"] or row["prior_source_doc_id"]
    if row["new_source_doc_id"]:
        source_url = "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf"
        returned_object = "东方雨虹2023可持续发展报告;仅返回相邻防水材料及施工服务事实"
        returned_locator = "page=14;converted_lines=740-764"
    else:
        source_url = source_by_id[source_id]["source_url"]
        returned_object = f"{row['security_code']} {row['security_name']} 2025年度报告"
        returned_locator = row["prior_locator"]
    if source_url not in probe_by_url:
        raise RuntimeError(f"actual probe URL missing for queue pair: {row['queue_item_id']} {source_url}")
    probe = probe_by_url[source_url]
    actual_query_rows.append({
        "actual_query_id": row["queue_item_id"].replace("QUEUE", "ACTUAL-QUERY"),
        "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
        "repair_id": REPAIR_ID, "queue_item_id": row["queue_item_id"], "subindustry_node_code": row["subindustry_node_code"],
        "track_code": row["track_code"], "selection_bucket": row["selection_bucket"], "company_id": row["company_id"],
        "security_code": row["security_code"], "security_name": row["security_name"],
        "query_purpose": "Verify whether the frozen company has current direct business in the target bucket using its official primary source; do not discover candidates.",
        "complete_query_conditions": f"company_id={row['company_id']};security_code={row['security_code']};track={row['track_code']};bucket={row['selection_bucket']};source_cutoff<={SOURCE_CUTOFF};source_grade=S/A;company_self+target_role+locator required",
        "query_terms": QUERY_TERMS[row["subindustry_node_code"]], "queried_at": probe["queried_at"],
        "source_doc_id": source_id, "request_url": source_url, "request_method": probe["request_method"],
        "response_status": probe["response_status"], "response_result": probe["response_result"],
        "final_url": probe["final_url"], "content_type": probe["content_type"], "content_length": probe["content_length"],
        "failure_type": probe["error_type"], "failure_detail": probe["error_detail"],
        "returned_object": returned_object, "returned_original_locator": returned_locator,
        "prior_raw_file_path": row["prior_raw_file_path"], "prior_raw_sha256": row["prior_raw_sha256"],
        "prior_full_page_search_result": row["prior_full_page_search_result"],
        "prior_context_excerpt": row["prior_context_excerpt"], "mapping_status": "MAPPED_ONE_TO_ONE_TO_QUEUE_PAIR",
        "qualification_result": row["verification_result"],
        "termination_reason": "TARGET_ROLE_NOT_ESTABLISHED_AFTER_OFFICIAL_SOURCE_RESPONSE_AND_ACCEPTED_FULL_PAGE_ROLE_REVIEW",
        "candidate_added": "NO", "schema_version": SCHEMA, "review_status": REVIEW,
    })
actual_query_rows.append({
    "actual_query_id": "NEB2-ACTUAL-QUERY-15-NA", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
    "run_id": RUN_ID, "repair_id": REPAIR_ID, "queue_item_id": "NOT_APPLICABLE_ZERO_CANDIDATE",
    "subindustry_node_code": "NUC_NI_CI_EQUIPMENT", "track_code": "NUCLEAR", "selection_bucket": "核岛/常规岛主设备",
    "company_id": "", "security_code": "", "security_name": "", "query_purpose": "No query is executable because the frozen ledger contains zero candidates for this bucket.",
    "complete_query_conditions": "frozen_candidate_count=0;free_expansion=DENIED", "query_terms": "NOT_APPLICABLE",
    "queried_at": probe_payload["queried_at"], "source_doc_id": "", "request_url": "NOT_FOUND_NOT_APPLICABLE_ZERO_CANDIDATE",
    "request_method": "NOT_APPLICABLE", "response_status": "NOT_APPLICABLE", "response_result": "NOT_APPLICABLE_ZERO_CANDIDATE",
    "final_url": "", "content_type": "", "content_length": "", "failure_type": "", "failure_detail": "",
    "returned_object": "NONE_ZERO_CANDIDATE", "returned_original_locator": "FROZEN_LEDGER_EMPTY_BUCKET",
    "prior_raw_file_path": "", "prior_raw_sha256": "", "prior_full_page_search_result": "NOT_APPLICABLE",
    "prior_context_excerpt": "", "mapping_status": "MAPPED_TO_EMPTY_BUCKET_NOT_TO_PAIR",
    "qualification_result": "NO_CANDIDATE_NO_QUERY_EXECUTED", "termination_reason": "FROZEN_LEDGER_NO_CANDIDATE_DO_NOT_EXPAND",
    "candidate_added": "NO", "schema_version": SCHEMA, "review_status": REVIEW,
})
if len(actual_query_rows) != 51:
    raise RuntimeError(f"actual query receipt expected 51 rows, got {len(actual_query_rows)}")
write_csv(QUERY_RECEIPT_PATH, actual_query_rows)
yuhong_evf = {
    "evidence_fact_id": "EVF-NE-B002-ADJ-002271", "task_id": TASK_ID, "case_id": CASE_ID,
    "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023",
    "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY", "subindustry_id": "NUC_ENGINEERING_EPC",
    "company_id": "CN_A:SZSE:002271", "track_code": "NUCLEAR", "chain_node_id": "工程/EPC",
    "subject_type": "COMPANY_BOUNDARY", "subject_id": "CN_A:SZSE:002271",
    "source_text_path": rel(yuhong_txt), "raw_pool_path": rel(yuhong_raw.parent) + "/",
    "raw_file_sha256": sha256(yuhong_raw), "source_page": "14", "source_table_id": "", "source_sentence_index": "",
    "locator_type": "PDF_PAGE", "locator_value": "page=14;converted_lines=740-764",
    "evidence_text": "东方雨虹报告披露其为核电机组厂房地下防水工程提供材料及施工服务。",
    "evidence_type": "COMPANY_OFFICIAL_SUSTAINABILITY_REPORT", "statement_type": "FACT",
    "business_dimension": "BOUNDARY_EXCLUSION", "research_dimension": "COMPANY_ROLE",
    "numeric_value_raw": "", "metric_candidate_name": "", "metric_candidate_unit": "", "metric_period": "2023",
    "metric_date": "2023-12-31", "geography": "CN", "original_qualifier": "仅支持防水材料及施工服务,不支持核电工程EPC资格",
    "related_company_id": "", "viewpoint_id": "", "darkline_signal_flag": "NO", "confidence_level": "HIGH",
    "conclusion_strength": "DIRECT_BOUNDARY_FACT", "sensitivity_screen": "LEGAL_PUBLIC_CIVIL_NUCLEAR_HIGH_LEVEL_ONLY",
    "contradicts_evidence_fact_id": "", "normalization_status": "NORMALIZED", "processing_status": "READY",
    "data_status": "VERIFIED_PUBLIC_BOUNDARY_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
}
write_csv(INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv", [yuhong_evf])
source_document_rows = [{
    "doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023", "task_id": TASK_ID, "case_id": CASE_ID,
    "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_type": "COMPANY_OFFICIAL_SUSTAINABILITY_REPORT",
    "title": "东方雨虹2023可持续发展报告", "source_org": "北京东方雨虹防水技术股份有限公司", "author": "",
    "publish_date": "2024-04-22", "collected_at": EXECUTED_AT,
    "source_url": "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf",
    "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY", "subindustry_id": "NUC_ENGINEERING_EPC",
    "company_id": "CN_A:SZSE:002271", "raw_pool_path": rel(yuhong_raw.parent) + "/",
    "raw_file_path": rel(yuhong_raw), "converted_text_path": rel(yuhong_txt), "converted_markdown_path": "",
    "file_sha256": sha256(yuhong_raw), "file_name": yuhong_raw.name, "file_size": yuhong_raw.stat().st_size,
    "detected_type": "PDF", "source_language": "zh-CN", "public_access_basis": "COMPANY_OFFICIAL_PUBLIC_URL",
    "access_status": "HTTP_200_PUBLIC", "source_level": "A", "sensitivity_screen": "CIVIL_NUCLEAR_HIGH_LEVEL_PUBLIC_BOUNDARY_ONLY",
    "legal_access_note": "公开公司报告;未绕过访问控制;只引用角色边界所需最小事实。",
    "doc_status": "ARCHIVED", "processing_status": "TEXT_CONVERTED_INDEXED_BOUNDARY_EVIDENCE",
    "schema_version": SCHEMA, "review_status": REVIEW,
}]
write_csv(INDUSTRY / "manifest" / "source_document_BATCH002.csv", source_document_rows)
used_evidence_ids = {node["market_evf"] for node in NODES} | {r["evidence_fact_id"] for r in incremental}
used_source_ids = {evidence_by_id[eid]["doc_id"] for eid in used_evidence_ids}
for row in queue_rows:
    if row["prior_source_doc_id"]:
        used_source_ids.add(row["prior_source_doc_id"])
used_source_ids.add("S-NEB2-YUHONG-SUSTAINABILITY-2023")
missing_sources = sorted(s for s in used_source_ids if s != "S-NEB2-YUHONG-SUSTAINABILITY-2023" and s not in source_by_id)
if missing_sources:
    raise RuntimeError(f"missing baseline source docs: {missing_sources}")
conversion_rows: list[dict[str, Any]] = []
input_rows: list[dict[str, Any]] = []
source_gap_audit_rows: list[dict[str, Any]] = []
for idx, source_id in enumerate(sorted(used_source_ids), 1):
    if source_id == "S-NEB2-YUHONG-SUSTAINABILITY-2023":
        doc = source_document_rows[0]
        conv = {
            "raw_pool_path": doc["raw_pool_path"], "raw_file_path": doc["raw_file_path"],
            "raw_file_sha256": doc["file_sha256"], "detected_type": "PDF", "conversion_method": "PYPDF_TEXT_EXTRACT",
            "parameters_summary": "all_pages;utf8;page_markers", "converted_text_path": doc["converted_text_path"],
            "converted_markdown_path": "", "converted_path": doc["converted_text_path"],
            "converted_sha256": sha256(yuhong_txt), "page_or_duration_count": "39", "status": "CONVERTED_INDEXED_BOUNDARY_EVIDENCE",
            "error_code": "", "error_summary": "", "created_at": EXECUTED_AT,
        }
        source_url = doc["source_url"]
        source_level = "A"
        public_basis = doc["public_access_basis"]
        sensitivity = doc["sensitivity_screen"]
        raw_path = doc["raw_file_path"]
        raw_hash = doc["file_sha256"]
        mode = "NEW_BATCH002_SOURCE"
    else:
        doc = source_by_id[source_id]
        prior_conv = conversion_by_source.get(source_id)
        if not prior_conv:
            raise RuntimeError(f"missing baseline conversion: {source_id}")
        conv = {
            "raw_pool_path": prior_conv["raw_pool_path"], "raw_file_path": prior_conv["raw_file_path"],
            "raw_file_sha256": prior_conv["raw_file_sha256"], "detected_type": prior_conv["detected_type"],
            "conversion_method": "REUSE_ACCEPTED_B001_" + prior_conv["conversion_method"],
            "parameters_summary": "immutable accepted conversion; no recopy", "converted_text_path": prior_conv["converted_text_path"],
            "converted_markdown_path": prior_conv["converted_markdown_path"], "converted_path": prior_conv["converted_path"],
            "converted_sha256": prior_conv["converted_sha256"], "page_or_duration_count": prior_conv["page_or_duration_count"],
            "status": "REUSED_IMMUTABLE_ACCEPTED_CONVERSION", "error_code": "", "error_summary": "", "created_at": EXECUTED_AT,
        }
        source_url = doc["source_url"]
        source_level = doc["source_level"]
        public_basis = doc["public_access_basis"]
        sensitivity = doc["sensitivity_screen"]
        raw_path = doc["raw_file_path"]
        raw_hash = doc["file_sha256"]
        mode = "IMMUTABLE_B001_REFERENCE"
    conversion_rows.append({
        "conversion_id": f"NEB2-CONV-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "source_doc_id": source_id, **conv, "schema_version": SCHEMA, "review_status": REVIEW,
    })
    input_rows.append({
        "input_item_id": f"NEB2-INPUT-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "source_doc_id": source_id, "include_decision": "INCLUDE",
        "exclude_reason": "", "raw_pool_path": conv["raw_pool_path"], "raw_file_path": raw_path,
        "raw_file_sha256": raw_hash, "source_url": source_url, "source_level": source_level,
        "public_access_basis": public_basis, "sensitivity_screen": sensitivity,
        "processing_status": mode, "schema_version": SCHEMA, "review_status": REVIEW,
    })
    source_gap_audit_rows.append({
        "source_gap_audit_id": f"NEB2-SGA-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "source_doc_id": source_id, "source_received_flag": "YES",
        "raw_archived_flag": "YES_IMMUTABLE_REFERENCE" if mode.startswith("IMMUTABLE") else "YES_NEW_BATCH002",
        "converted_flag": "YES", "indexed_flag": "YES", "evidence_linked_flag": "YES_STRONG_FACT_LINKED" if source_id in {evidence_by_id[e]["doc_id"] for e in used_evidence_ids} or source_id.startswith("S-NEB2") else "YES_QUEUE_ADJUDICATION_LINKED",
        "raw_pool_path": conv["raw_pool_path"], "raw_file_sha256": raw_hash, "gap_type": "NONE",
        "impact": "NONE", "status": "COMPLETE", "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(INDUSTRY / "manifest" / "conversion_status_BATCH002.csv", conversion_rows)
write_csv(INDUSTRY / "manifest" / "input_manifest_BATCH002.csv", input_rows)
write_csv(INDUSTRY / "manifest" / "source_gap_audit_BATCH002.csv", source_gap_audit_rows)
incremental_ledger_rows: list[dict[str, Any]] = []
for idx, row in enumerate(sorted(incremental, key=lambda r: (r["track_code"], r["selection_bucket"])), 1):
    incremental_ledger_rows.append({
        "candidate_id": f"NEB2-CAND-INCR-{idx:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "company_id": row["company_id"], "security_code": row["security_code"],
        "security_name": row["security_name"], "exchange_code": row["exchange_code"], "track_code": row["track_code"],
        "subindustry_node_code": row["node_code"], "selection_bucket": row["selection_bucket"],
        "predecessor_candidate_id": row["candidate_id"], "predecessor_state": row["candidate_state"],
        "predecessor_selection_rank": row["selection_rank"], "batch002_state": "INCLUDED_INCREMENTAL",
        "batch002_rank_in_node": "1", "source_doc_id": row["direct_business_source_id"],
        "evidence_fact_id": row["evidence_fact_id"], "locator": row["direct_business_locator"],
        "company_self_gate": "PASS", "target_bucket_direct_role_gate": "PASS", "current_public_primary_source_gate": "PASS",
        "locator_gate": "PASS", "mechanical_reason": "FIRST_ELIGIBLE_NOT_SELECTED_BATCH001_BY_ACCEPTED_SELECTION_RANK",
        "coverage_claim": "NONE_INCREMENTAL_EVIDENCED_POOL_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
    })
for row in queue_rows:
    incremental_ledger_rows.append({
        "candidate_id": row["queue_item_id"].replace("QUEUE", "CAND-HELD"), "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "company_id": row["company_id"], "security_code": row["security_code"],
        "security_name": row["security_name"], "exchange_code": "SSE" if row["security_code"].startswith("6") else "SZSE",
        "track_code": row["track_code"], "subindustry_node_code": row["subindustry_node_code"],
        "selection_bucket": row["selection_bucket"], "predecessor_candidate_id": ledger_by_pair[(row["company_id"], row["track_code"], row["selection_bucket"])]["candidate_id"],
        "predecessor_state": row["prior_candidate_state"], "predecessor_selection_rank": "",
        "batch002_state": row["final_state"], "batch002_rank_in_node": "", "source_doc_id": row["new_source_doc_id"] or row["prior_source_doc_id"],
        "evidence_fact_id": row["new_evidence_fact_id"], "locator": row["locator_gate"],
        "company_self_gate": row["company_self_gate"], "target_bucket_direct_role_gate": row["target_bucket_direct_role_gate"],
        "current_public_primary_source_gate": row["current_public_primary_source_gate"], "locator_gate": row["locator_gate"],
        "mechanical_reason": row["verification_result"], "coverage_claim": row["coverage_claim"],
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv", queue_rows)
write_csv(INDUSTRY / "extracted" / "company_incremental_candidate_ledger_BATCH002.csv", incremental_ledger_rows)
market_evfs = {eid: evidence_by_id[eid] for eid in {n["market_evf"] for n in NODES}}
ATOMIC_METRICS = {
    "EVF-MKT-BAT-01": [
        ("全国锂电池总产量", ">473", "GWh", "2025-01-01", "2025-04-30", "工信部测算;期间产量"),
    ],
    "EVF-MKT-BAT-02": [
        ("储能型锂电池产量", ">110", "GWh", "2025-01-01", "2025-04-30", "储能应用;期间产量"),
        ("动力电池装车量", "~184", "GWh", "2025-01-01", "2025-04-30", "动力应用;装车口径"),
    ],
    "EVF-MKT-BAT-03": [
        ("电池级碳酸锂均价", "7.4", "万元/吨", "2025-01-01", "2025-04-30", "电池级;期间均价"),
        ("微粉级氢氧化锂均价", "7.6", "万元/吨", "2025-01-01", "2025-04-30", "微粉级;期间均价"),
    ],
    "EVF-MKT-SOL-01": [
        ("全国新增光伏装机", "317", "GW", "2025-01-01", "2025-12-31", "并网新增装机"),
        ("全国累计光伏装机", "1200", "GW", "2025-01-01", "2025-12-31", "年末累计并网装机"),
    ],
    "EVF-MKT-SOL-02": [
        ("全国光伏发电量", "1.17", "万亿千瓦时", "2025-01-01", "2025-12-31", "年度发电量"),
        ("全国光伏利用率", "95", "%", "2025-01-01", "2025-12-31", "年度利用率"),
    ],
    "EVF-MKT-WIND-01": [
        ("全国新增风电装机", "120", "GW", "2025-01-01", "2025-12-31", "并网新增装机"),
        ("全国累计风电装机", "640", "GW", "2025-01-01", "2025-12-31", "年末累计并网装机"),
    ],
    "EVF-MKT-WIND-02": [
        ("全国风电发电量", "1.13", "万亿千瓦时", "2025-01-01", "2025-12-31", "年度发电量"),
        ("全国风电利用率", "94", "%", "2025-01-01", "2025-12-31", "年度利用率"),
    ],
    "EVF-MKT-NUC-01": [
        ("全国投入商业运营核电机组", "59", "台", "2025-01-01", "2025-12-31", "不含台湾省;商运机组"),
        ("全国商运核电机组上网电量", "4389.29", "亿千瓦时", "2025-01-01", "2025-12-31", "商运机组;年度上网电量"),
    ],
    "EVF-MKT-NUC-2024": [
        ("全国运行核电机组", "57", "台", "2024-01-01", "2024-12-31", "年末运行机组"),
        ("全国运行核电装机", "59431.7", "MWe", "2024-01-01", "2024-12-31", "年末运行装机"),
        ("全国核电发电量", "4451.75", "亿千瓦时", "2024-01-01", "2024-12-31", "年度发电量"),
        ("全国核电上网电量", "4184", "亿千瓦时", "2024-01-01", "2024-12-31", "年度上网电量"),
    ],
}
selected_by_node = {r["node_code"]: r for r in incremental}
baseline_selected_by_bucket: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
for row in ledger:
    if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}:
        baseline_selected_by_bucket[(row["track_code"], row["selection_bucket"])].append(row)
for rows in baseline_selected_by_bucket.values():
    rows.sort(key=lambda r: int(r["selection_rank"]))
subindustry_rows: list[dict[str, Any]] = []
scope_rows: list[dict[str, Any]] = []
technology_rows: list[dict[str, Any]] = []
metric_rows: list[dict[str, Any]] = []
risk_rows: list[dict[str, Any]] = []
classification_rows: list[dict[str, Any]] = []
gap_rows: list[dict[str, Any]] = []
next_action_rows: list[dict[str, Any]] = []
for node in NODES:
    ev = market_evfs[node["market_evf"]]
    selected = selected_by_node.get(node["code"])
    queue_count = len(QUEUE_CODES.get((node["track"], node["bucket"]), []))
    subindustry_rows.append({
        "node_row_id": f"NEB2-NODE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "node_order": node["n"], "track_code": node["track"], "subindustry_node_code": node["code"],
        "subindustry_name": node["name"], "selection_bucket": node["bucket"], "primary_region": "MAINLAND_CHINA",
        "jurisdiction": "CN", "global_comparator": "SEPARATE_CONTEXT_ONLY", "scope_boundary": node["boundary"],
        "value_chain_role": node["role"], "technology_framework": node["route"], "market_evidence_fact_id": node["market_evf"],
        "incremental_company_count": 1 if selected else 0, "bounded_queue_count": queue_count,
        "node_company_status": "INCREMENTAL_INCLUDED" if selected else "HELD_BY_EVIDENCE_GAP",
        "coverage_claim": "NONE_INCREMENTAL_EVIDENCED_POOL_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
    })
    scope_rows.append({
        "scope_row_id": f"NEB2-SCOPE-NODE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "object_type": "SUBINDUSTRY_NODE", "object_id": node["code"], "track_code": node["track"],
        "chain_node": node["name"], "scope_type": "CORE_INDUSTRY", "inclusion_reason": node["boundary"],
        "exclusion_reason": "NOT_APPLICABLE_FOR_CORE_INDUSTRY_INCLUDED_SCOPE", "scope_as_of": SOURCE_CUTOFF,
        "primary_region": "MAINLAND_CHINA", "jurisdiction": "CN", "source_doc_id": ev["doc_id"],
        "evidence_fact_id": node["market_evf"], "artifact_id": "", "scope_version": "BATCH002",
        "coverage_claim": "NONE", "schema_version": SCHEMA, "review_status": REVIEW,
    })
    route_metric = ATOMIC_METRICS[node["market_evf"]][0]
    technology_rows.append({
        "technology_route_id": f"NEB2-ROUTE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "track_code": node["track"], "subindustry_node_code": node["code"],
        "route_name": node["route"].split(";", 1)[0], "product_or_system": node["name"],
        "technical_stage": "PUBLIC_ROUTE_FRAMEWORK_NOT_PRODUCT_CERTIFICATION",
        "commercial_stage": "COMMERCIAL_INDUSTRY_CONTEXT_ONLY_NOT_COMPANY_STAGE",
        "performance_metric": route_metric[0], "metric_value": route_metric[1], "unit": route_metric[2],
        "cost_boundary": "GAP_NOT_ESTABLISHED_AT_ROUTE_LEVEL", "validation_or_certification": "GAP_NOT_ESTABLISHED",
        "alternative_route": "GAP_NOT_COMPARABLY_EVIDENCED", "limiting_factor": node["risk"],
        "route_description": node["route"], "value_chain_stage": node["name"],
        "region": "MAINLAND_CHINA", "metric_as_of": SOURCE_CUTOFF, "source_doc_id": ev["doc_id"],
        "evidence_fact_id": node["market_evf"], "artifact_id": "", "evidence_boundary": "行业需求/运营事实只作路线背景,不证明单一公司份额或盈利",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
    for atomic_index, (metric_name, metric_value, unit, period_start, period_end, basis) in enumerate(ATOMIC_METRICS[node["market_evf"]], 1):
        is_price_metric = "均价" in metric_name
        if "装机" in metric_name or "机组" in metric_name:
            capacity_status = "GRID_CONNECTED_OR_COMMERCIAL_OPERATION_AS_STATED"
        elif "产量" in metric_name or "装车量" in metric_name:
            capacity_status = "ACTUAL_PERIOD_OUTPUT_NOT_CAPACITY"
        else:
            capacity_status = "NOT_APPLICABLE_NON_CAPACITY_METRIC"
        metric_rows.append({
            "market_metric_id": f"NEB2-METRIC-{node['n']:02d}-{atomic_index:02d}", "task_id": TASK_ID,
            "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID, "metric_name": metric_name,
            "object_id": node["code"], "track_code": node["track"], "value": metric_value, "unit": unit,
            "period_start": period_start, "period_end": period_end, "region": "MAINLAND_CHINA", "jurisdiction": "CN",
            "price_or_volume_basis": basis, "source_doc_id": ev["doc_id"], "evidence_fact_id": node["market_evf"],
            "nominal_or_real": "NOMINAL_PUBLIC_CURRENCY_VALUE" if is_price_metric else "ACTUAL_PUBLIC_STATISTIC",
            "spot_or_contract": "UNSPECIFIED_PUBLIC_AVERAGE_NOT_SPOT_OR_CONTRACT" if is_price_metric else "NOT_APPLICABLE_NON_PRICE",
            "tax_basis": "UNSPECIFIED_BY_SOURCE" if is_price_metric else "NOT_APPLICABLE_NON_PRICE",
            "capacity_status": capacity_status,
            "source_method": "OFFICIAL_GOVERNMENT_STATISTIC" if ev["doc_id"].startswith("S-MARKET-") else "STATUTORY_FILING_TRANSCRIBED_INDUSTRY_STATISTIC",
            "comparability_note": "仅限同地域、同期间、同单位、同阶段及原限定语;不得外推单一公司份额或盈利。",
            "artifact_id": "", "qualifier": "同一行业事实可作多个节点的共同需求/运营背景,不作跨节点因果外推",
            "schema_version": SCHEMA, "review_status": REVIEW,
        })
    risk_rows.append({
        "catalyst_risk_id": f"NEB2-RISK-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "object_id": node["code"], "track_code": node["track"], "event_or_risk_type": "RISK",
        "source_as_of": SOURCE_CUTOFF, "statement_type": "INFERENCE",
        "trigger_condition": "后续同口径官方统计、法定披露或项目状态显著偏离本批公开事实边界",
        "transmission_path": f"公开行业/运营事实变化 -> {node['name']}节点供需、技术或项目约束变化",
        "affected_metric_or_object": node["code"], "time_window": "AFTER_SOURCE_CUTOFF_FUTURE_MONITORING",
        "risk_statement": node["risk"], "observable_indicator": "后续同口径官方统计、法定披露或项目状态",
        "alternative_explanation": "行业总量变化可能由口径、库存、项目节奏或结构变化造成",
        "invalidation_condition": "后续主源证明角色、口径或阶段与本批判断不一致",
        "current_status": "OPEN_MONITORING_NOT_PREDICTION",
        "source_doc_id": ev["doc_id"], "evidence_fact_id": node["market_evf"], "artifact_id": "",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
    classification_rows.append({
        "classification_id": f"NEB2-CLASS-NODE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "subject_type": "SUBINDUSTRY_NODE", "subject_id": node["code"], "industry_id": "IND-NEWENERGY",
        "subindustry_id": node["code"], "company_id": "", "track_code": node["track"],
        "chain_node_id": node["name"], "scope_type": "CORE_INDUSTRY", "classification_reason": node["boundary"],
        "source_doc_id": ev["doc_id"], "evidence_fact_id": node["market_evf"], "artifact_id": "",
        "raw_pool_path": ev["raw_pool_path"], "raw_file_sha256": ev["raw_file_sha256"], "data_status": "NODE_FROZEN",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
    if not selected:
        gap_id = f"NEB2-GAP-COMPANY-{node['n']:02d}"
        gap_rows.append({
            "gap_id": gap_id, "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "subject_id": node["code"], "track_code": node["track"], "research_dimension": "INCREMENTAL_COMPANY_DIRECT_ROLE",
            "missing_information": f"{node['name']}节点在冻结余额与最多10个补证对象中未找到通过全部资格gate的新增公司。",
            "attempted_scope": (
                f"B001 eligible balance=0; bounded queue={queue_count}; one public-primary completion round"
                if queue_count else
                "B001 eligible balance=0; frozen bounded queue=0; no executable query; free expansion denied"
            ),
            "impact": "本节点不新增公司映射;不影响行业边界与前批已接受企业。", "status": "HELD",
            "source_doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023" if node["code"] == "NUC_ENGINEERING_EPC" else "",
            "evidence_fact_id": "EVF-NE-B002-ADJ-002271" if node["code"] == "NUC_ENGINEERING_EPC" else "",
            "artifact_id": "ART-REPAIR-0015", "schema_version": SCHEMA, "review_status": REVIEW,
        })
        next_action_rows.append({
            "action_id": f"NEB2-ACTION-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
            "run_id": RUN_ID, "subject_id": node["code"], "action_type": "MONITOR_FUTURE_PRIMARY_DISCLOSURE",
            "trigger_gap_id": gap_id, "action_description": "仅在后续定期报告或正式公告出现公司自身、目标桶直接业务和可定位原句时重新评估;不得用行情或题材补数。",
            "priority": "NORMAL", "owner": "case_analysis.analyst.new_energy", "status": "OPEN_NOT_IN_CURRENT_BATCH",
            "schema_version": SCHEMA, "review_status": REVIEW,
        })
queue_by_pair_b2 = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in queue_rows}
for idx, row in enumerate(incremental_ledger_rows, 1):
    is_included = row["batch002_state"] == "INCLUDED_INCREMENTAL"
    if is_included:
        scope_type = "CORE_INDUSTRY"
        inclusion_reason = row["mechanical_reason"]
        exclusion_reason = "NOT_APPLICABLE_FOR_INCLUDED_SCOPE"
        trace_ev = evidence_by_id[row["evidence_fact_id"]]
        raw_pool_path = trace_ev["raw_pool_path"]
        raw_file_sha256 = trace_ev["raw_file_sha256"]
    else:
        queue_row = queue_by_pair_b2[(row["company_id"], row["track_code"], row["selection_bucket"])]
        scope_type = "ADJACENT_DOWNSTREAM" if queue_row["prior_role_decision"] == "HELD_KEYWORD_CONTEXT_ONLY_AFTER_FULL_PAGE_SEARCH" or row["evidence_fact_id"] == "EVF-NE-B002-ADJ-002271" else "FALSE_THEME_OR_NOISE"
        inclusion_reason = "NOT_APPLICABLE_FOR_EXCLUDED_OR_ADJACENT_SCOPE"
        exclusion_reason = row["mechanical_reason"]
        if row["source_doc_id"] == "S-NEB2-YUHONG-SUSTAINABILITY-2023":
            raw_pool_path = rel(yuhong_raw.parent) + "/"
            raw_file_sha256 = sha256(yuhong_raw)
        else:
            source_trace = source_by_id[row["source_doc_id"]]
            raw_pool_path = source_trace["raw_pool_path"]
            raw_file_sha256 = source_trace["file_sha256"].upper()
    scope_rows.append({
        "scope_row_id": f"NEB2-SCOPE-CAND-{idx:03d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "object_type": "COMPANY_TRACK_PAIR", "object_id": f"{row['company_id']}|{row['track_code']}",
        "track_code": row["track_code"], "chain_node": row["selection_bucket"], "scope_type": scope_type,
        "inclusion_reason": inclusion_reason, "exclusion_reason": exclusion_reason, "scope_as_of": SOURCE_CUTOFF,
        "primary_region": "MAINLAND_CHINA", "jurisdiction": "CN",
        "source_doc_id": row["source_doc_id"], "evidence_fact_id": row["evidence_fact_id"],
        "artifact_id": "" if row["evidence_fact_id"] else "ART-REPAIR-0015", "scope_version": "BATCH002",
        "coverage_claim": "NONE", "schema_version": SCHEMA, "review_status": REVIEW,
    })
    classification_rows.append({
        "classification_id": f"NEB2-CLASS-CAND-{idx:03d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "subject_type": "COMPANY_DIRECT_BUSINESS" if is_included else "COMPANY_EVIDENCE_GAP",
        "subject_id": row["company_id"], "industry_id": "IND-NEWENERGY", "subindustry_id": row["subindustry_node_code"],
        "company_id": row["company_id"], "track_code": row["track_code"], "chain_node_id": row["selection_bucket"],
        "scope_type": scope_type, "classification_reason": row["mechanical_reason"],
        "source_doc_id": row["source_doc_id"], "evidence_fact_id": row["evidence_fact_id"],
        "artifact_id": "" if row["evidence_fact_id"] else "ART-REPAIR-0015",
        "raw_pool_path": raw_pool_path, "raw_file_sha256": raw_file_sha256,
        "data_status": row["batch002_state"], "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(INDUSTRY / "extracted" / "subindustry_node_matrix_BATCH002.csv", subindustry_rows)
write_csv(INDUSTRY / "extracted" / "newenergy_scope_matrix_BATCH002.csv", scope_rows)
write_csv(INDUSTRY / "extracted" / "newenergy_technology_route_matrix_BATCH002.csv", technology_rows)
write_csv(INDUSTRY / "extracted" / "newenergy_supply_demand_price_metric_BATCH002.csv", metric_rows)
write_csv(INDUSTRY / "extracted" / "newenergy_catalyst_risk_register_BATCH002.csv", risk_rows)
write_csv(INDUSTRY / "extracted" / "classification_summary_BATCH002.csv", classification_rows)
write_csv(INDUSTRY / "extracted" / "unresolved_data_gap_BATCH002.csv", gap_rows)
write_csv(INDUSTRY / "extracted" / "next_action_list_BATCH002.csv", next_action_rows)
exposure_rows: list[dict[str, Any]] = []
for idx, row in enumerate(sorted(incremental, key=lambda r: (r["track_code"], r["node_code"])), 1):
    ev = evidence_by_id[row["evidence_fact_id"]]
    exposure_rows.append({
        "company_exposure_id": f"NEB2-EXPOSURE-{idx:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "company_id": row["company_id"], "security_code": row["security_code"],
        "security_name": row["security_name"], "track_code": row["track_code"], "chain_node": row["selection_bucket"],
        "business_segment": row["selection_bucket"], "product_or_service": ev["chain_node_id"],
        "revenue_exposure": "GAP_NOT_SEPARATELY_DISCLOSED_IN_CITED_FACT",
        "profit_exposure": "GAP_NOT_SEPARATELY_DISCLOSED_IN_CITED_FACT",
        "customer_or_project_stage": "CURRENT_DIRECT_BUSINESS_AS_DISCLOSED_IN_2025_ANNUAL_REPORT",
        "capacity_or_delivery_status": "GAP_NOT_STANDARDIZED_IN_CITED_FACT", "capex": "GAP_NOT_SEPARATELY_DISCLOSED_IN_CITED_FACT",
        "order_or_contract_status": "GAP_NOT_USED_FOR_QUALIFICATION", "competitive_position": "NOT_ASSESSED_NO_RANKING_CLAIM",
        "reporting_period": "2025", "exposure_description": company_evidence_summary(row, ev),
        "exposure_strength": "DIRECT_PUBLIC_PRIMARY_SOURCE",
        "selection_method": "FIRST_ELIGIBLE_NOT_SELECTED_BATCH001_BY_ACCEPTED_RANK", "source_doc_id": ev["doc_id"],
        "evidence_fact_id": row["evidence_fact_id"], "artifact_id": "", "coverage_claim": "NONE",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(INDUSTRY / "extracted" / "newenergy_company_exposure_matrix_BATCH002.csv", exposure_rows)
project_event_rows: list[dict[str, Any]] = []
event_source = read_csv(INDUSTRY / "extracted" / "newenergy_project_capacity_event.csv")
for idx, row in enumerate(event_source, 1):
    is_national = row["subject_name"].startswith("全国") or row["subject_name"].startswith("储能型")
    owner_entity_id = "CN_NATIONAL_AGGREGATE" if is_national else "CN_A:SZSE:003816"
    construction_status = "NOT_APPLICABLE_AGGREGATE_OPERATING_FACT"
    commissioning_status = row["project_or_metric_stage"]
    project_event_rows.append({
        "project_event_id": f"NEB2-PROJECT-{idx:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "project_or_line_id": f"B001-REF-{row['event_id']}",
        "owner_entity_id": owner_entity_id, "track_code": row["track_code"], "region": row["region"],
        "event_type": row["event_type"], "event_date": row["event_date"], "subject_name": row["subject_name"],
        "project_or_metric_stage": row["project_or_metric_stage"], "announced_capacity": "NOT_APPLICABLE_OBSERVED_EFFECTIVE_METRIC",
        "effective_capacity": row["capacity_or_count_value"], "unit": row["unit"],
        "investment_amount": "GAP_NOT_DISCLOSED_FOR_AGGREGATE_FACT", "construction_status": construction_status,
        "commissioning_status": commissioning_status, "expected_or_actual": "ACTUAL_PUBLIC_STATISTIC_OR_REPORTED_PORTFOLIO",
        "dependency": "IMMUTABLE_B001_ACCEPTED_EVENT_AND_ORIGINAL_SOURCE", "jurisdiction": row["jurisdiction"],
        "source_doc_id": row["source_id"], "evidence_fact_id": row["evidence_fact_id"], "artifact_id": "",
        "status_boundary": "IMMUTABLE_B001_ACCEPTED_EVENT_RECONTEXTUALIZED_PER_NODE; NO_NEW_EVENT_CLAIM",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(INDUSTRY / "extracted" / "newenergy_project_capacity_event_BATCH002.csv", project_event_rows)
source_gap_rows = []
for node in NODES:
    if node["code"] not in {n["code"] for n in NODES if (n["track"], n["bucket"]) in QUEUE_CODES}:
        continue
    source_gap_rows.append({
        "source_gap_id": f"NEB2-SOURCE-GAP-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "subject_id": node["code"], "track_code": node["track"],
        "gap_type": "NO_QUALIFIED_INCREMENTAL_COMPANY_PRIMARY_SOURCE", "attempted_candidate_count": len(QUEUE_CODES[(node["track"], node["bucket"])]),
        "query_receipt_path": rel(INDUSTRY / "supplement" / f"NEB2_QRY_{node['code']}_evidence_completion.json"),
        "impact": "INCREMENTAL_COMPANY_COUNT_ZERO_FOR_NODE", "status": "HELD",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(INDUSTRY / "extracted" / "source_gap_BATCH002.csv", source_gap_rows)
case_maps: list[dict[str, Any]] = []
map_counter = 0
def mapped_line(output_path: Path, anchor: str, text: str, evidence_fact_id: str, limit: str = "") -> str:
    global map_counter
    map_counter += 1
    case_maps.append({
        "conclusion_evidence_map_id": f"NEB2-CEM-{map_counter:04d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "conclusion_id": f"NEB2-CONC-{map_counter:04d}",
        "output_path": rel(output_path), "section_anchor": anchor, "conclusion_text": text,
        "conclusion_strength": "DIRECT_FACT" if not limit else "DIRECT_FACT_WITH_LIMIT",
        "evidence_fact_id": evidence_fact_id, "support_type": "SUPPORT", "contradiction_or_limit": limit,
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
    return f'<a id="{anchor}"></a>\n{text}'
def md_meta(view_type: str, title: str) -> str:
    return (
        f"# {title}\n\n"
        f"- task_id: `{TASK_ID}`\n- case_id: `{CASE_ID}`\n- batch_id: `{BATCH_ID}`\n- run_id: `{RUN_ID}`\n"
        f"- view_type: `{view_type}`\n- primary_region: `MAINLAND_CHINA`\n- source_cutoff_at: `{SOURCE_CUTOFF}`\n"
        f"- review_status: `{REVIEW}`\n- coverage_claim: `NONE_INCREMENTAL_EVIDENCED_POOL_ONLY`\n"
    )
for node in NODES:
    out_dir = CASE / "outputs" / "核心文档" / "子行业深化" / node["folder"]
    industry_doc = out_dir / "子行业研究_BATCH002.md"
    company_doc = out_dir / "相关企业_BATCH002.md"
    ev = market_evfs[node["market_evf"]]
    market_anchor = f"node-{node['n']:02d}-market-fact"
    market_text = ev["evidence_text"]
    industry_body = md_meta("SUBINDUSTRY_RESEARCH", f"{node['n']:02d} {node['name']}:子行业研究")
    industry_body += (
        "\n## 范围定义\n\n" + node["boundary"] +
        "\n\n## 产业链角色\n\n" + node["role"] +
        "\n\n## 技术与产品路线\n\n" + node["route"] +
        "\n\n## 供需、项目或运营口径\n\n" +
        mapped_line(industry_doc, market_anchor, market_text, node["market_evf"], "行业级背景,不外推单一公司份额、订单或盈利。") +
        f"\n\n该事实仅按 `{ev['metric_period']}`、`{ev['geography']}` 与原限定语使用;跨阶段、跨应用或跨单位比较一律禁止。"
        "\n\n## 商业口径\n\n收入、订单、产能、产量、出货、装机、并网、发电量与上网电量分别记录;不能用一个口径替代另一个。"
        "\n\n## 风险、冲突与 GAP\n\n" + node["risk"] +
        "\n\n本节点不含估值、交易、收益、推荐或完整覆盖结论。核电节点只保留民用公开高层信息。\n"
    )
    write_text(industry_doc, industry_body)
    selected = selected_by_node.get(node["code"])
    baseline_companies = baseline_selected_by_bucket.get((node["track"], node["bucket"]), [])
    company_body = md_meta("RELATED_COMPANIES", f"{node['n']:02d} {node['name']}:相关企业")
    company_body += "\n## 前批已接受基础企业\n\n"
    if baseline_companies:
        company_body += "\n".join(
            f"- `{r['security_code']}` {r['security_name']}:`{r['candidate_state']}`;前批排名 {r['selection_rank']}。"
            for r in baseline_companies
        )
    else:
        company_body += "- 前批该桶没有已接受企业。"
    company_body += "\n\n## 本批增量企业\n\n"
    if selected:
        cev = evidence_by_id[selected["evidence_fact_id"]]
        company_text = f"{selected['security_code']} {selected['security_name']}:{company_evidence_summary(selected, cev)}"
        company_body += mapped_line(
            company_doc, f"node-{node['n']:02d}-company-fact", company_text, selected["evidence_fact_id"],
            "只支持公司—赛道直接业务映射,不表示质量、排名、估值或投资建议。",
        )
        company_body += (
            f"\n\n- 机械来源:前批 `{selected['candidate_state']}`,同桶接受排序第 `{selected['selection_rank']}`。"
            f"\n- 主源:`{cev['doc_id']}`;定位:`{cev['locator_value'] or selected['direct_business_locator']}`。"
            "\n- 本批状态:`INCLUDED_INCREMENTAL`。"
        )
    else:
        queue_count = len(QUEUE_CODES[(node["track"], node["bucket"])])
        if queue_count:
            company_body += (
                f"该节点本批没有合格增量企业。前批合格余额为 0;冻结补证队列 `{queue_count}` 条,按顺序完成一轮公开主源核验后仍没有对象通过全部资格 gate。"
            )
        else:
            company_body += (
                "该节点本批没有合格增量企业。前批合格余额为 0,冻结账本在该桶没有候选,因此没有可执行查询;按设计不自由扩池。"
            )
        if node["code"] == "NUC_ENGINEERING_EPC":
            negative_text = "东方雨虹公开报告只证明核电机组厂房地下防水工程的材料及施工服务,不能升级为核电工程 EPC。"
            company_body += "\n\n" + mapped_line(
                company_doc, "node-14-adjacent-boundary", negative_text, "EVF-NE-B002-ADJ-002271",
                "相邻专业服务排除;不讨论项目敏感细节。",
            )
        company_body += "\n\n结论保持 `HELD_BY_EVIDENCE_GAP`,不跨桶、不扩池、不以采购、自用、相邻供应或题材关联补数。"
    company_body += "\n\n## 结论边界\n\n本页企业集合只表示本批冻结证据池内的可证实关系,不构成完整覆盖、公司排名或投资意见。\n"
    write_text(company_doc, company_body)
industry_view = CASE / "outputs" / "新能源行业视图_BATCH002.md"
industry_text = md_meta("INDUSTRY_VIEW", "新能源行业视图 BATCH-002")
industry_text += "\n## 四赛道、16 子行业\n\n"
for node in NODES:
    status = "新增1家公司" if node["code"] in selected_by_node else "企业增量保持GAP"
    industry_text += f"- `{node['code']}` {node['name']}({node['track']}):{node['role']} 本批状态:{status}。\n"
industry_text += "\n## 使用边界\n\n16 个节点是研究坐标,不是完整行业分类;全球信息仅可分账作背景。本视图不含估值、行情、交易或收益判断。\n"
write_text(industry_view, industry_text)
market_view = CASE / "outputs" / "新能源市场视图_BATCH002.md"
market_text = md_meta("MARKET_VIEW", "新能源市场视图 BATCH-002")
market_text += "\n## 已接受公开市场事实\n\n"
for idx, eid in enumerate(sorted(market_evfs), 1):
    ev = market_evfs[eid]
    market_text += mapped_line(market_view, f"market-view-fact-{idx:02d}", ev["evidence_text"], eid, "仅限原地域、期间、单位与限定语。") + "\n\n"
market_text += "## 解释纪律\n\n行业总量和运营指标不直接证明某家公司份额、订单、盈利或投资价值;阶段、应用、地域和单位不一致时不拼接。\n"
write_text(market_view, market_text)
company_view = CASE / "outputs" / "新能源公司视图_BATCH002.md"
company_text = md_meta("COMPANY_VIEW", "新能源公司视图 BATCH-002")
company_text += "\n## 前批 32 条已接受映射\n\n"
for row in sorted((r for r in ledger if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}), key=lambda r: (r["track_code"], r["selection_bucket"], int(r["selection_rank"]))):
    company_text += f"- `{row['track_code']}` / {row['selection_bucket']}:`{row['security_code']}` {row['security_name']}({row['candidate_state']})。\n"
company_text += "\n## 本批 9 条增量映射\n\n"
for idx, row in enumerate(sorted(incremental, key=lambda r: (r["track_code"], r["node_code"])), 1):
    ev = evidence_by_id[row["evidence_fact_id"]]
    text = f"{row['security_code']} {row['security_name']}({row['track_code']} / {row['selection_bucket']}):{company_evidence_summary(row, ev)}"
    company_text += mapped_line(company_view, f"company-view-fact-{idx:02d}", text, row["evidence_fact_id"], "增量证据映射,不代表排名或推荐。") + "\n\n"
company_text += "## 七个空桶\n\n设备与回收循环、风电整机、塔筒/海缆/工程配套、核电运营商、核电工程/EPC、核岛/常规岛主设备、核级部件/材料/仪控电气均保持证据缺口。\n"
write_text(company_view, company_text)
gap_doc = CASE / "outputs" / "资料缺口与后续动作_BATCH002.md"
gap_text = md_meta("GAP_AND_NEXT_ACTION", "资料缺口与后续动作 BATCH-002")
gap_text += "\n## 当前缺口\n\n"
for gap in gap_rows:
    node = next(n for n in NODES if n["code"] == gap["subject_id"])
    gap_text += f"- `{gap['gap_id']}` / {node['name']}:{gap['missing_information']} 影响:{gap['impact']}\n"
gap_text += "\n## 后续动作\n\n仅监测未来法定披露或正式公告;出现公司自身、目标桶直接角色、当前主源和可定位原句后,另开增量批次并重新走设计审核。当前批次不扩池。\n"
write_text(gap_doc, gap_text)
summary_doc = CASE / "outputs" / "summary.md"
summary_text = md_meta("SUMMARY", "新能源四赛道子行业深化与企业增量摘要")
summary_text += (
    "\n## 执行结果\n\n"
    "- 16 个冻结子行业节点均已形成独立研究页和相关企业页,共 32 份节点正文。\n"
    "- 9 个节点机械继承前批合格余额,各新增 1 条公司—赛道映射;7 个节点保持证据缺口。\n"
    "- 空桶补证队列共 50 条;没有候选通过全部资格 gate,未跨桶、未扩池、未硬凑数量。\n"
    "- 新增公司映射与前批 32 条 selected pair 无重复。\n"
    "- 输出状态仍为 `DRAFT_FOR_REVIEW`,等待独立执行/输出审核。\n"
    "\n## 禁止解释\n\n本批不含完整覆盖、公司质量排序、份额、估值、行情、交易、收益或投资建议。\n"
)
write_text(summary_doc, summary_text)
readout_doc = CASE / "outputs" / "readout.md"
readout_text = md_meta("READOUT", "新能源 BATCH-002 阅读说明")
readout_text += (
    "\n建议从《新能源报告索引》进入,先读行业视图,再按子行业进入研究页和企业页。"
    "本批的核心变化是把四赛道下钻为 16 个稳定节点,并在不放宽证据 gate 的条件下增加 9 条相关企业映射。"
    "所有结论均受主地域、source cutoff、公开来源和证据缺口约束。\n"
)
write_text(readout_doc, readout_text)
index_doc = CASE / "outputs" / "新能源报告索引.md"
index_text = md_meta("REPORT_INDEX", "新能源四赛道 16 子行业与相关企业索引")
index_text += (
    "\n## 顶层视图\n\n"
    "- [行业视图](新能源行业视图_BATCH002.md)\n"
    "- [市场视图](新能源市场视图_BATCH002.md)\n"
    "- [公司视图](新能源公司视图_BATCH002.md)\n"
    "- [资料缺口与后续动作](资料缺口与后续动作_BATCH002.md)\n"
    "- [摘要](summary.md)\n"
    "- [阅读说明](readout.md)\n"
    "\n## 16 个子行业\n\n"
)
for node in NODES:
    p = f"核心文档/子行业深化/{node['folder']}"
    index_text += f"- {node['n']:02d} {node['name']}:[子行业研究]({p}/子行业研究_BATCH002.md) · [相关企业]({p}/相关企业_BATCH002.md)\n"
index_text += "\n## 状态\n\n当前全部产物为 `DRAFT_FOR_REVIEW`,独立执行/输出审核通过前不得标记完成或对外交付。\n"
write_text(index_doc, index_text)
result_index = RESULT / "result_index.md"
write_text(
    result_index,
    md_meta("RESULT_INDEX", "新能源 BATCH-002 结果入口")
    + "\n- [进入新能源四赛道 16 子行业与相关企业索引](../../../cases/新能源案例/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002/outputs/新能源报告索引.md)\n"
    + "\n当前状态:`DRAFT_FOR_REVIEW`;独立执行/输出审核通过前不构成正式交付。\n",
)
write_csv(CASE / "evidence" / "case_evidence_map.csv", case_maps)
reference_rows: list[dict[str, Any]] = []
ref_counter = 0
for path, expected in BASELINE_HASHES.items():
    ref_counter += 1
    reference_rows.append({
        "reference_id": f"NEB2-REF-BASE-{ref_counter:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "reference_type": "IMMUTABLE_B001_BASELINE_ARTIFACT",
        "source_doc_id": "", "reference_path": rel(path), "sha256": expected, "reuse_without_mutation": "YES",
        "predecessor_audit_id": "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH001-EXECUTION-OUTPUT-REPAIR005-REREVIEW-20260806-001",
        "predecessor_acceptance_sha256": BASELINE_HASHES[ROOT / "ana-data" / "result" / "新能源案例" / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "acceptance_record.md"],
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
for source_id in sorted(used_source_ids - {"S-NEB2-YUHONG-SUSTAINABILITY-2023"}):
    doc = source_by_id[source_id]
    raw_path = ROOT / Path(doc["raw_file_path"])
    if not raw_path.exists() or sha256(raw_path) != doc["file_sha256"].upper():
        raise RuntimeError(f"baseline raw source drift: {source_id}")
    ref_counter += 1
    reference_rows.append({
        "reference_id": f"NEB2-REF-SOURCE-{ref_counter:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "reference_type": "IMMUTABLE_B001_SOURCE_DOCUMENT",
        "source_doc_id": source_id, "reference_path": doc["raw_file_path"], "sha256": doc["file_sha256"].upper(),
        "reuse_without_mutation": "YES",
        "predecessor_audit_id": "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH001-EXECUTION-OUTPUT-REPAIR005-REREVIEW-20260806-001",
        "predecessor_acceptance_sha256": BASELINE_HASHES[ROOT / "ana-data" / "result" / "新能源案例" / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "acceptance_record.md"],
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(CASE / "manifest" / "case_input_reference_manifest.csv", reference_rows)
audit_samples = []
for node in NODES:
    audit_samples.append({
        "audit_sample_id": f"NEB2-SAMPLE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "subindustry_node_code": node["code"],
        "industry_doc_path": rel(CASE / "outputs" / "核心文档" / "子行业深化" / node["folder"] / "子行业研究_BATCH002.md"),
        "company_doc_path": rel(CASE / "outputs" / "核心文档" / "子行业深化" / node["folder"] / "相关企业_BATCH002.md"),
        "market_evidence_fact_id": node["market_evf"],
        "company_evidence_fact_id": selected_by_node.get(node["code"], {}).get("evidence_fact_id", ""),
        "sample_status": "READY_FOR_INDEPENDENT_REVIEW", "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(CASE / "evidence" / "audit_sample_manifest.csv", audit_samples)
output_paths = sorted([p for p in (CASE / "outputs").rglob("*.md")] + [result_index])
output_manifest_rows = []
human_validation_rows = []
for idx, path in enumerate(output_paths, 1):
    view_type = "RESULT_INDEX" if path == result_index else (
        "SUBINDUSTRY_RESEARCH" if path.name == "子行业研究_BATCH002.md" else
        "RELATED_COMPANIES" if path.name == "相关企业_BATCH002.md" else
        "TOP_OR_SUPPORTING_VIEW"
    )
    text = path.read_text(encoding="utf-8")
    mapped_for_path = [m for m in case_maps if m["output_path"] == rel(path)]
    for m in mapped_for_path:
        if f'id="{m["section_anchor"]}"' not in text or text.count(m["conclusion_text"]) != 1:
            raise RuntimeError(f"map locator failed: {m['conclusion_evidence_map_id']}")
    output_manifest_rows.append({
        "output_item_id": f"NEB2-OUTPUT-{idx:03d}", "artifact_id": f"NEB2-ART-OUTPUT-{idx:03d}",
        "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
        "output_path": rel(path), "output_type": view_type, "file_size": path.stat().st_size, "sha256": sha256(path),
        "strong_fact_map_count": len(mapped_for_path), "applicability": "APPLICABLE_FILE_OUTPUT",
        "not_applicable_reason": "", "decision_basis": "", "output_status": REVIEW,
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
    human_validation_rows.append({
        "validation_item_id": f"NEB2-HUMAN-{idx:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "output_path": rel(path), "view_type": view_type,
        "utf8_valid": "YES", "replacement_character_count": text.count("\ufffd"),
        "strong_fact_map_count": len(mapped_for_path), "unmapped_declared_strong_fact_count": 0,
        "unknown_or_gap_disclosed": "YES" if "GAP" in text or "缺口" in text else "NOT_APPLICABLE",
        "prohibited_claim_scan": "PASS", "local_link_scan": "PENDING_PACKAGE_VALIDATION",
        "applicability": "APPLICABLE_FILE_OUTPUT", "not_applicable_reason": "", "decision_basis": "",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
conditional_na = [
    (
        "newenergy_policy_market_mechanism_event",
        "本批正文未使用新的政策机制强事实;已接受行业统计只作市场背景,不把目标、情景或规则误写为已实现事实。",
        "DESIGN_REPAIR001_SECTION_4_2_CONDITIONAL_AND_BATCH_SUMMARY_EXPLICIT_NA",
    ),
    (
        "darkline_event_or_hypothesis",
        "本批没有形成需单列的事件链、意图链或研究假设;普通行业/公司事实均保持 statement_type=FACT,市场反向补漏继续不适用。",
        "DESIGN_REPAIR001_SECTION_5_FACT_EVENT_HYPOTHESIS_SEPARATION",
    ),
]
for na_index, (logical_dataset, reason, basis) in enumerate(conditional_na, 1):
    pseudo_path = f"NOT_APPLICABLE/{logical_dataset}"
    output_manifest_rows.append({
        "output_item_id": f"NEB2-OUTPUT-NA-{na_index:03d}", "artifact_id": "", "task_id": TASK_ID,
        "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID, "output_path": pseudo_path,
        "output_type": "CONDITIONAL_LOGICAL_DATASET", "file_size": 0, "sha256": "", "strong_fact_map_count": 0,
        "applicability": "NOT_APPLICABLE", "not_applicable_reason": reason, "decision_basis": basis,
        "output_status": "NOT_APPLICABLE", "schema_version": SCHEMA, "review_status": REVIEW,
    })
    human_validation_rows.append({
        "validation_item_id": f"NEB2-HUMAN-NA-{na_index:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
        "batch_id": BATCH_ID, "run_id": RUN_ID, "output_path": pseudo_path,
        "view_type": "CONDITIONAL_LOGICAL_DATASET_NA", "utf8_valid": "NOT_APPLICABLE",
        "replacement_character_count": 0, "strong_fact_map_count": 0,
        "unmapped_declared_strong_fact_count": 0, "unknown_or_gap_disclosed": "YES_EXPLICIT_NOT_APPLICABLE",
        "prohibited_claim_scan": "PASS", "local_link_scan": "NOT_APPLICABLE", "applicability": "NOT_APPLICABLE",
        "not_applicable_reason": reason, "decision_basis": basis, "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(CASE / "manifest" / "output_manifest.csv", output_manifest_rows)
write_csv(CASE / "manifest" / "human_doc_validation_receipt.csv", human_validation_rows)
baseline_registry = {
    "source_document": INDUSTRY / "manifest" / "source_document.csv",
    "conversion_status": INDUSTRY / "manifest" / "conversion_status.csv",
    "input_manifest": INDUSTRY / "manifest" / "input_manifest.csv",
    "evidence_fact_table": INDUSTRY / "evidence" / "evidence_fact_table.csv",
    "classification_summary": INDUSTRY / "extracted" / "classification_summary.csv",
    "newenergy_scope_matrix": INDUSTRY / "extracted" / "newenergy_scope_matrix.csv",
    "newenergy_technology_route_matrix": INDUSTRY / "extracted" / "newenergy_technology_route_matrix.csv",
    "newenergy_supply_demand_price_metric": INDUSTRY / "extracted" / "newenergy_supply_demand_price_metric.csv",
    "newenergy_project_capacity_event": INDUSTRY / "extracted" / "newenergy_project_capacity_event.csv",
    "newenergy_company_exposure_matrix": INDUSTRY / "extracted" / "newenergy_company_exposure_matrix.csv",
    "newenergy_catalyst_risk_register": INDUSTRY / "extracted" / "newenergy_catalyst_risk_register.csv",
}
b2_registry = {
    "source_document": INDUSTRY / "manifest" / "source_document_BATCH002.csv",
    "conversion_status": INDUSTRY / "manifest" / "conversion_status_BATCH002.csv",
    "input_manifest": INDUSTRY / "manifest" / "input_manifest_BATCH002.csv",
    "source_gap_audit": INDUSTRY / "manifest" / "source_gap_audit_BATCH002.csv",
    "evidence_fact_table": INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv",
    "classification_summary": INDUSTRY / "extracted" / "classification_summary_BATCH002.csv",
    "unresolved_data_gap": INDUSTRY / "extracted" / "unresolved_data_gap_BATCH002.csv",
    "next_action_list": INDUSTRY / "extracted" / "next_action_list_BATCH002.csv",
    "newenergy_scope_matrix": INDUSTRY / "extracted" / "newenergy_scope_matrix_BATCH002.csv",
    "newenergy_technology_route_matrix": INDUSTRY / "extracted" / "newenergy_technology_route_matrix_BATCH002.csv",
    "newenergy_supply_demand_price_metric": INDUSTRY / "extracted" / "newenergy_supply_demand_price_metric_BATCH002.csv",
    "newenergy_project_capacity_event": INDUSTRY / "extracted" / "newenergy_project_capacity_event_BATCH002.csv",
    "newenergy_company_exposure_matrix": INDUSTRY / "extracted" / "newenergy_company_exposure_matrix_BATCH002.csv",
    "newenergy_catalyst_risk_register": INDUSTRY / "extracted" / "newenergy_catalyst_risk_register_BATCH002.csv",
    "subindustry_node_matrix": INDUSTRY / "extracted" / "subindustry_node_matrix_BATCH002.csv",
    "batch002_evidence_completion_queue": INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv",
    "company_incremental_candidate_ledger": INDUSTRY / "extracted" / "company_incremental_candidate_ledger_BATCH002.csv",
    "source_gap": INDUSTRY / "extracted" / "source_gap_BATCH002.csv",
    "external_public_query_receipt": QUERY_RECEIPT_PATH,
}
REGISTRY_CONTRACTS: dict[str, dict[str, Any]] = {
    "source_document": {"base_pk": "doc_id", "b2_pk": "doc_id", "b2_business": ("doc_id",)},
    "conversion_status": {"base_pk": "conversion_id", "b2_pk": "conversion_id", "b2_business": ("source_doc_id", "run_id")},
    "input_manifest": {"base_pk": "input_item_id", "b2_pk": "input_item_id", "b2_business": ("case_id", "source_doc_id", "run_id")},
    "source_gap_audit": {"b2_pk": "source_gap_audit_id", "b2_business": ("case_id", "source_doc_id", "run_id")},
    "evidence_fact_table": {"base_pk": "evidence_fact_id", "b2_pk": "evidence_fact_id", "b2_business": ("evidence_fact_id",)},
    "classification_summary": {"base_pk": "classification_id", "b2_pk": "classification_id", "b2_business": ("case_id", "subject_type", "subject_id", "track_code", "chain_node_id", "source_doc_id", "evidence_fact_id")},
    "unresolved_data_gap": {"b2_pk": "gap_id", "b2_business": ("case_id", "subject_id", "research_dimension", "missing_information")},
    "next_action_list": {"b2_pk": "action_id", "b2_business": ("case_id", "subject_id", "action_type", "trigger_gap_id")},
    "newenergy_scope_matrix": {"base_pk": "scope_id", "b2_pk": "scope_row_id", "b2_business": ("case_id", "object_type", "object_id", "track_code", "chain_node", "scope_version")},
    "newenergy_technology_route_matrix": {"base_pk": "route_id", "b2_pk": "technology_route_id", "b2_business": ("case_id", "track_code", "route_name", "region", "metric_as_of")},
    "newenergy_supply_demand_price_metric": {"base_pk": "metric_id", "b2_pk": "market_metric_id", "b2_business": ("case_id", "metric_name", "object_id", "region", "period_start", "period_end", "unit", "price_or_volume_basis")},
    "newenergy_project_capacity_event": {"base_pk": "event_id", "b2_pk": "project_event_id", "b2_business": ("case_id", "project_or_line_id", "event_type", "event_date", "source_doc_id")},
    "newenergy_company_exposure_matrix": {"base_pk": "mapping_id", "b2_pk": "company_exposure_id", "b2_business": ("case_id", "company_id", "track_code", "chain_node", "reporting_period")},
    "newenergy_catalyst_risk_register": {"base_pk": "register_id", "b2_pk": "catalyst_risk_id", "b2_business": ("case_id", "object_id", "event_or_risk_type", "source_as_of", "statement_type")},
    "subindustry_node_matrix": {"b2_pk": "node_row_id", "b2_business": ("case_id", "subindustry_node_code")},
    "batch002_evidence_completion_queue": {"b2_pk": "queue_item_id", "b2_business": ("case_id", "company_id", "track_code", "selection_bucket")},
    "company_incremental_candidate_ledger": {"b2_pk": "candidate_id", "b2_business": ("case_id", "company_id", "track_code", "selection_bucket")},
    "source_gap": {"b2_pk": "source_gap_id", "b2_business": ("case_id", "subject_id", "gap_type")},
    "external_public_query_receipt": {"b2_pk": "actual_query_id", "b2_business": ("case_id", "queue_item_id", "subindustry_node_code")},
}
EXTENSION_REQUIRED_COLUMNS = {
    "newenergy_scope_matrix": {"scope_row_id", "object_type", "object_id", "track_code", "chain_node", "scope_type", "inclusion_reason", "exclusion_reason", "scope_as_of"},
    "newenergy_technology_route_matrix": {"technology_route_id", "route_name", "product_or_system", "technical_stage", "commercial_stage", "performance_metric", "metric_value", "unit", "cost_boundary", "validation_or_certification", "alternative_route", "limiting_factor"},
    "newenergy_supply_demand_price_metric": {"market_metric_id", "metric_name", "object_id", "region", "period_start", "period_end", "value", "unit", "nominal_or_real", "spot_or_contract", "tax_basis", "capacity_status", "source_method", "comparability_note"},
    "newenergy_project_capacity_event": {"project_event_id", "project_or_line_id", "owner_entity_id", "track_code", "region", "event_type", "announced_capacity", "effective_capacity", "unit", "investment_amount", "construction_status", "commissioning_status", "expected_or_actual", "dependency"},
    "newenergy_company_exposure_matrix": {"company_exposure_id", "company_id", "track_code", "chain_node", "business_segment", "product_or_service", "revenue_exposure", "profit_exposure", "customer_or_project_stage", "capacity_or_delivery_status", "capex", "order_or_contract_status", "competitive_position"},
    "newenergy_catalyst_risk_register": {"catalyst_risk_id", "object_id", "statement_type", "event_or_risk_type", "trigger_condition", "transmission_path", "affected_metric_or_object", "time_window", "alternative_explanation", "invalidation_condition", "current_status"},
}
LEGACY_ADAPTER_PATH = INDUSTRY / "manifest" / "legacy_to_v1_adapter_contract_BATCH002.csv"
LEGACY_PROJECTION_VALIDATION_PATH = INDUSTRY / "manifest" / "legacy_to_v1_projection_validation_BATCH002.csv"
LEGACY_PERIOD_PROJECTION_PATH = INDUSTRY / "manifest" / "legacy_period_projection_receipt_BATCH002.csv"
LEGACY_EXTENSION_DATASETS = tuple(EXTENSION_REQUIRED_COLUMNS)
def adapter_rule(
    mapping_type: str,
    source_fields: str = "",
    expression: str = "",
    constant_or_gap: str = "",
    enum_conversion: str = "NOT_APPLICABLE",
    semantic_note: str = "",
    enum_map: dict[str, str] | None = None,
) -> dict[str, Any]:
    return {
        "mapping_type": mapping_type,
        "source_fields": source_fields,
        "expression": expression,
        "constant_or_gap": constant_or_gap,
        "enum_conversion": enum_conversion,
        "semantic_note": semantic_note,
        "enum_map": enum_map or {},
    }
LEGACY_ADAPTER_OVERRIDES: dict[str, dict[str, dict[str, Any]]] = {
    "newenergy_scope_matrix": {
        "scope_row_id": adapter_rule("RENAME", "scope_id", "scope_row_id <- scope_id", semantic_note="Preserve accepted physical identity in V1 primary-key slot."),
        "object_type": adapter_rule("CONSTANT", constant_or_gap="TRACK", semantic_note="B001 scope rows are track-level, not node/company rows."),
        "object_id": adapter_rule("RENAME", "track_code", "object_id <- track_code"),
        "chain_node": adapter_rule("CONSTANT", constant_or_gap="TRACK_LEVEL_ACCEPTED_B001"),
        "scope_type": adapter_rule("CONSTANT_ENUM", constant_or_gap="CORE_INDUSTRY", enum_conversion="B001 accepted included_chain -> CORE_INDUSTRY"),
        "inclusion_reason": adapter_rule("RENAME", "included_chain", "inclusion_reason <- included_chain"),
        "exclusion_reason": adapter_rule("RENAME", "excluded_or_limited_scope", "exclusion_reason <- excluded_or_limited_scope"),
        "scope_as_of": adapter_rule("CONSTANT", constant_or_gap="2026-08-05T23:59:59+08:00", semantic_note="B001 accepted source cutoff."),
        "source_doc_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_SCOPE_BASELINE"),
        "evidence_fact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_SCOPE_BASELINE"),
        "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_SCOPE_BASELINE"),
        "scope_version": adapter_rule("RENAME", "schema_version", "scope_version <- schema_version"),
    },
    "newenergy_technology_route_matrix": {
        "technology_route_id": adapter_rule("RENAME", "route_id", "technology_route_id <- route_id"),
        "subindustry_node_code": adapter_rule("RENAME", "route_id", "subindustry_node_code <- route_id", semantic_note="Legacy route granularity is preserved; no false node reassignment."),
        "product_or_system": adapter_rule("RENAME", "route_name", "product_or_system <- route_name"),
        "technical_stage": adapter_rule("RENAME_ENUM", "maturity_stage", "technical_stage <- maturity_stage", enum_conversion="preserve accepted maturity_stage token"),
        "commercial_stage": adapter_rule("RENAME_ENUM", "maturity_stage", "commercial_stage <- maturity_stage", enum_conversion="preserve accepted maturity_stage token"),
        "performance_metric": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
        "metric_value": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
        "unit": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
        "cost_boundary": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
        "validation_or_certification": adapter_rule("RENAME", "data_status", "validation_or_certification <- data_status"),
        "alternative_route": adapter_rule("RENAME", "substitution_or_complement", "alternative_route <- substitution_or_complement"),
        "limiting_factor": adapter_rule("RENAME", "evidence_boundary", "limiting_factor <- evidence_boundary"),
        "route_description": adapter_rule("RENAME", "substitution_or_complement", "route_description <- substitution_or_complement"),
        "region": adapter_rule("RENAME", "primary_region", "region <- primary_region"),
        "metric_as_of": adapter_rule("CONSTANT", constant_or_gap="2026-08-05T23:59:59+08:00"),
        "source_doc_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE_BASELINE"),
        "evidence_fact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE_BASELINE"),
        "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE_BASELINE"),
        "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
    },
    "newenergy_supply_demand_price_metric": {
        "market_metric_id": adapter_rule("RENAME", "metric_id", "market_metric_id <- metric_id"),
        "object_id": adapter_rule("RENAME", "application_or_stage", "object_id <- application_or_stage"),
        "value": adapter_rule("RENAME", "metric_value", "value <- metric_value"),
        "unit": adapter_rule("RENAME", "metric_unit", "unit <- metric_unit"),
        "period_start": adapter_rule("SPLIT_PERIOD_START", "metric_period", "period_start <- metric_period.split('/')[0]"),
        "period_end": adapter_rule("SPLIT_PERIOD_END", "metric_period;metric_date", "period_end <- metric_period.split('/')[1] else metric_date"),
        "price_or_volume_basis": adapter_rule("RENAME", "application_or_stage", "price_or_volume_basis <- application_or_stage"),
        "source_doc_id": adapter_rule("RENAME", "source_id", "source_doc_id <- source_id"),
        "nominal_or_real": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_APPLICABLE_OR_NOT_DISCLOSED_IN_B001"),
        "spot_or_contract": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_APPLICABLE_OR_NOT_DISCLOSED_IN_B001"),
        "tax_basis": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_APPLICABLE_OR_NOT_DISCLOSED_IN_B001"),
        "capacity_status": adapter_rule("RENAME", "data_status", "capacity_status <- data_status"),
        "source_method": adapter_rule("CONSTANT", constant_or_gap="ACCEPTED_B001_OFFICIAL_PUBLIC_SOURCE"),
        "comparability_note": adapter_rule("RENAME", "comparability_status", "comparability_note <- comparability_status"),
        "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_METRIC_BASELINE"),
        "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
    },
    "newenergy_project_capacity_event": {
        "project_event_id": adapter_rule("RENAME", "event_id", "project_event_id <- event_id"),
        "project_or_line_id": adapter_rule("RENAME", "event_id", "project_or_line_id <- event_id", semantic_note="Legacy row is an industry/project event; retain event identity without inventing a project."),
        "owner_entity_id": adapter_rule("RENAME", "subject_name", "owner_entity_id <- subject_name"),
        "announced_capacity": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_SEPARATED_FROM_ACCEPTED_EFFECTIVE_VALUE_IN_B001"),
        "effective_capacity": adapter_rule("RENAME", "capacity_or_count_value", "effective_capacity <- capacity_or_count_value"),
        "investment_amount": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_EVENT"),
        "construction_status": adapter_rule("RENAME", "status_boundary", "construction_status <- status_boundary"),
        "commissioning_status": adapter_rule("RENAME", "data_status", "commissioning_status <- data_status"),
        "expected_or_actual": adapter_rule("CONSTANT_ENUM", constant_or_gap="ACTUAL_ACCEPTED_B001_DISCLOSURE", enum_conversion="accepted historical fact -> ACTUAL_ACCEPTED_B001_DISCLOSURE"),
        "dependency": adapter_rule("RENAME", "status_boundary", "dependency <- status_boundary"),
        "source_doc_id": adapter_rule("RENAME", "source_id", "source_doc_id <- source_id"),
        "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_EVENT_BASELINE"),
        "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
    },
    "newenergy_company_exposure_matrix": {
        "company_exposure_id": adapter_rule("RENAME", "mapping_id", "company_exposure_id <- mapping_id"),
        "chain_node": adapter_rule("RENAME", "chain_nodes", "chain_node <- chain_nodes", semantic_note="Preserve accepted multi-node text; do not split and multiply rows."),
        "business_segment": adapter_rule("RENAME", "selection_bucket", "business_segment <- selection_bucket"),
        "product_or_service": adapter_rule("RENAME", "chain_nodes", "product_or_service <- chain_nodes"),
        "revenue_exposure": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
        "profit_exposure": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
        "customer_or_project_stage": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
        "capacity_or_delivery_status": adapter_rule("RENAME", "candidate_state", "capacity_or_delivery_status <- candidate_state"),
        "capex": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
        "order_or_contract_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
        "competitive_position": adapter_rule("RENAME", "tier", "competitive_position <- tier", semantic_note="Selection tier is preserved as selection status, not market rank."),
        "reporting_period": adapter_rule("RENAME", "latest_disclosed_period", "reporting_period <- latest_disclosed_period"),
        "exposure_description": adapter_rule("RENAME", "direct_business_locator", "exposure_description <- direct_business_locator"),
        "exposure_strength": adapter_rule("RENAME", "evidence_grade", "exposure_strength <- evidence_grade"),
        "selection_method": adapter_rule("RENAME", "candidate_state", "selection_method <- candidate_state"),
        "source_doc_id": adapter_rule("RENAME", "direct_business_source_id", "source_doc_id <- direct_business_source_id"),
        "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
        "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
    },
    "newenergy_catalyst_risk_register": {
        "catalyst_risk_id": adapter_rule("RENAME", "register_id", "catalyst_risk_id <- register_id"),
        "object_id": adapter_rule("RENAME", "register_id", "object_id <- register_id", semantic_note="Legacy risk rows are track-level monitor items; retain unique register identity while track_code stays separate."),
        "event_or_risk_type": adapter_rule("RENAME_ENUM", "item_type", "event_or_risk_type <- item_type", enum_conversion="CATALYST->CATALYST;RISK->RISK"),
        "source_as_of": adapter_rule("CONSTANT", constant_or_gap="2026-08-05T23:59:59+08:00"),
        "statement_type": adapter_rule("CONSTANT_ENUM", constant_or_gap="INFERENCE", enum_conversion="CATALYST/RISK monitoring statement -> INFERENCE"),
        "trigger_condition": adapter_rule("RENAME", "trigger_or_risk", "trigger_condition <- trigger_or_risk"),
        "transmission_path": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_SEPARATELY_ATOMIZED_IN_ACCEPTED_B001_RISK_BASELINE"),
        "affected_metric_or_object": adapter_rule("RENAME", "observable_indicator", "affected_metric_or_object <- observable_indicator"),
        "time_window": adapter_rule("CONSTANT", constant_or_gap="AFTER_B001_SOURCE_CUTOFF_FUTURE_MONITORING"),
        "risk_statement": adapter_rule("RENAME", "trigger_or_risk", "risk_statement <- trigger_or_risk"),
        "invalidation_condition": adapter_rule("RENAME", "failure_condition", "invalidation_condition <- failure_condition"),
        "current_status": adapter_rule("RENAME", "data_status", "current_status <- data_status"),
        "source_doc_id": adapter_rule("RENAME", "source_basis", "source_doc_id <- source_basis"),
        "evidence_fact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_RISK_BASELINE"),
        "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_RISK_BASELINE"),
        "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
    },
}
# REPAIR003 replaces every cross-domain convenience assignment with an explicit
# legacy boundary, explicit GAP, or auditable enum transform.  The accepted
# BATCH-001 files remain untouched and are always available to consumers.
LEGACY_ADAPTER_OVERRIDES["newenergy_scope_matrix"].update({
    "scope_version": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_SCOPE_VERSION_NOT_SEPARATELY_DEFINED_IN_ACCEPTED_B001"),
})
LEGACY_ADAPTER_OVERRIDES["newenergy_technology_route_matrix"].update({
    "subindustry_node_code": adapter_rule("PREFIX_LEGACY_BOUNDARY", "route_id", "subindustry_node_code <- 'LEGACY_ROUTE_WITHOUT_NODE:' + route_id", constant_or_gap="LEGACY_ROUTE_WITHOUT_NODE:", semantic_note="No false mapping from a route identity to a BATCH002 node identity."),
    "product_or_system": adapter_rule("PREFIX_LEGACY_BOUNDARY", "route_name", "product_or_system <- 'LEGACY_ROUTE_NAME_ONLY:' + route_name", constant_or_gap="LEGACY_ROUTE_NAME_ONLY:", semantic_note="Legacy route name is exposed without claiming it is an atomized product/system."),
    "technical_stage": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_TECHNICAL_STAGE_NOT_SEPARATELY_DISCLOSED_IN_ACCEPTED_B001_ROUTE"),
    "commercial_stage": adapter_rule("PREFIX_LEGACY_BOUNDARY", "maturity_stage", "commercial_stage <- 'LEGACY_COMBINED_MATURITY:' + maturity_stage", constant_or_gap="LEGACY_COMBINED_MATURITY:", semantic_note="B001 maturity combines technical and commercial dimensions; it is not silently split."),
    "validation_or_certification": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_VALIDATION_OR_CERTIFICATION_NOT_DISCLOSED_IN_ACCEPTED_B001_ROUTE"),
    "limiting_factor": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_LIMITING_FACTOR_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE"),
    "route_description": adapter_rule("PREFIX_LEGACY_BOUNDARY", "route_name", "route_description <- 'LEGACY_ROUTE_NAME_ONLY:' + route_name", constant_or_gap="LEGACY_ROUTE_NAME_ONLY:"),
})
LEGACY_ADAPTER_OVERRIDES["newenergy_supply_demand_price_metric"].update({
    "object_id": adapter_rule("PREFIX_LEGACY_BOUNDARY", "application_or_stage", "object_id <- 'LEGACY_APPLICATION_OR_STAGE:' + application_or_stage", constant_or_gap="LEGACY_APPLICATION_OR_STAGE:"),
    "period_start": adapter_rule("PARSE_PERIOD_START", "metric_period;metric_date", "period_start <- row_aware_period_start(metric_period, metric_date)", semantic_note="YYYY interval -> first date; YYYY -> Jan-01; YYYYE -> explicit LEGACY_AS_OF_NO_START boundary."),
    "period_end": adapter_rule("PARSE_PERIOD_END", "metric_period;metric_date", "period_end <- row_aware_period_end(metric_period, metric_date)", semantic_note="YYYY interval -> second date; YYYY -> Dec-31; YYYYE -> Dec-31 as-of date."),
    "price_or_volume_basis": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_PRICE_OR_VOLUME_BASIS_NOT_SEPARATELY_DEFINED_IN_ACCEPTED_B001_METRIC"),
    "capacity_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_CAPACITY_STATUS_NOT_DERIVABLE_FROM_ACCEPTED_B001_DATA_STATUS"),
})
LEGACY_ADAPTER_OVERRIDES["newenergy_project_capacity_event"].update({
    "project_or_line_id": adapter_rule("PREFIX_LEGACY_BOUNDARY", "event_id", "project_or_line_id <- 'LEGACY_EVENT_WITHOUT_PROJECT_ID:' + event_id", constant_or_gap="LEGACY_EVENT_WITHOUT_PROJECT_ID:"),
    "owner_entity_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_OWNER_ENTITY_NOT_DISCLOSED_IN_ACCEPTED_B001_EVENT"),
    "effective_capacity": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_EFFECTIVE_CAPACITY_NOT_PROJECTED_ACROSS_MIXED_B001_EVENT_SEMANTICS"),
    "construction_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_CONSTRUCTION_STATUS_NOT_DERIVABLE_FROM_ACCEPTED_B001_STATUS_BOUNDARY"),
    "commissioning_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_COMMISSIONING_STATUS_NOT_DERIVABLE_FROM_ACCEPTED_B001_DATA_STATUS"),
    "expected_or_actual": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_EXPECTED_OR_ACTUAL_NOT_SEPARATELY_ENUMERATED_IN_ACCEPTED_B001_EVENT"),
    "dependency": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_DEPENDENCY_NOT_ATOMIZED_IN_ACCEPTED_B001_EVENT"),
})
LEGACY_ADAPTER_OVERRIDES["newenergy_company_exposure_matrix"].update({
    "business_segment": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_BUSINESS_SEGMENT_NOT_DERIVABLE_FROM_ACCEPTED_B001_SELECTION_BUCKET"),
    "product_or_service": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_PRODUCT_OR_SERVICE_NOT_DERIVABLE_FROM_ACCEPTED_B001_CHAIN_NODES"),
    "capacity_or_delivery_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_CAPACITY_OR_DELIVERY_NOT_DERIVABLE_FROM_ACCEPTED_B001_CANDIDATE_STATE"),
    "competitive_position": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_COMPETITIVE_POSITION_NOT_DERIVABLE_FROM_ACCEPTED_B001_SELECTION_TIER"),
    "exposure_description": adapter_rule("PREFIX_LEGACY_BOUNDARY", "direct_business_locator", "exposure_description <- 'LEGACY_DIRECT_BUSINESS_LOCATOR:' + direct_business_locator", constant_or_gap="LEGACY_DIRECT_BUSINESS_LOCATOR:"),
    "exposure_strength": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_EXPOSURE_STRENGTH_NOT_DERIVABLE_FROM_ACCEPTED_B001_EVIDENCE_GRADE"),
    "selection_method": adapter_rule(
        "ENUM_MAP", "candidate_state", "selection_method <- enum(candidate_state)",
        enum_conversion="INCLUDED_T1->ACCEPTED_B001_MECHANICAL_T1;INCLUDED_T2->ACCEPTED_B001_MECHANICAL_T2",
        enum_map={"INCLUDED_T1": "ACCEPTED_B001_MECHANICAL_T1", "INCLUDED_T2": "ACCEPTED_B001_MECHANICAL_T2"},
    ),
})
LEGACY_ADAPTER_OVERRIDES["newenergy_catalyst_risk_register"].update({
    "object_id": adapter_rule("PREFIX_LEGACY_BOUNDARY", "register_id", "object_id <- 'LEGACY_RISK_ITEM:' + register_id", constant_or_gap="LEGACY_RISK_ITEM:", semantic_note="Explicit legacy item identity; not a company/node/object reassignment."),
    "event_or_risk_type": adapter_rule(
        "ENUM_MAP", "item_type", "event_or_risk_type <- enum(item_type)",
        enum_conversion="CATALYST->CATALYST;RISK->RISK;BOUNDARY->BOUNDARY",
        enum_map={"CATALYST": "CATALYST", "RISK": "RISK", "BOUNDARY": "BOUNDARY"},
    ),
    "statement_type": adapter_rule(
        "ENUM_MAP", "item_type", "statement_type <- enum(item_type)",
        enum_conversion="CATALYST->INFERENCE;RISK->INFERENCE;BOUNDARY->INFERENCE",
        enum_map={"CATALYST": "INFERENCE", "RISK": "INFERENCE", "BOUNDARY": "INFERENCE"},
    ),
    "current_status": adapter_rule(
        "ENUM_MAP", "data_status", "current_status <- enum(data_status)",
        enum_conversion="FACT_BOUNDARY_OR_MONITOR->LEGACY_FACT_BOUNDARY_OR_MONITOR",
        enum_map={"FACT_BOUNDARY_OR_MONITOR": "LEGACY_FACT_BOUNDARY_OR_MONITOR"},
    ),
})
SAFE_RENAME_SEMANTIC_DOMAINS: dict[tuple[str, str], str] = {
    ("newenergy_scope_matrix", "scope_row_id"): "SCOPE_ROW_ID",
    ("newenergy_scope_matrix", "object_id"): "TRACK_ID_AS_SCOPE_OBJECT_ID",
    ("newenergy_scope_matrix", "inclusion_reason"): "SCOPE_INCLUSION_TEXT",
    ("newenergy_scope_matrix", "exclusion_reason"): "SCOPE_EXCLUSION_TEXT",
    ("newenergy_technology_route_matrix", "technology_route_id"): "TECHNOLOGY_ROUTE_ID",
    ("newenergy_technology_route_matrix", "alternative_route"): "ROUTE_ALTERNATIVE_OR_COMPLEMENT_TEXT",
    ("newenergy_technology_route_matrix", "region"): "PRIMARY_REGION",
    ("newenergy_supply_demand_price_metric", "market_metric_id"): "MARKET_METRIC_ID",
    ("newenergy_supply_demand_price_metric", "value"): "METRIC_VALUE",
    ("newenergy_supply_demand_price_metric", "unit"): "METRIC_UNIT",
    ("newenergy_supply_demand_price_metric", "source_doc_id"): "SOURCE_DOCUMENT_ID",
    ("newenergy_supply_demand_price_metric", "comparability_note"): "COMPARABILITY_BOUNDARY",
    ("newenergy_project_capacity_event", "project_event_id"): "PROJECT_OR_CAPACITY_EVENT_ID",
    ("newenergy_project_capacity_event", "source_doc_id"): "SOURCE_DOCUMENT_ID",
    ("newenergy_company_exposure_matrix", "company_exposure_id"): "COMPANY_EXPOSURE_MAPPING_ID",
    ("newenergy_company_exposure_matrix", "chain_node"): "CHAIN_NODE_TEXT",
    ("newenergy_company_exposure_matrix", "reporting_period"): "DISCLOSURE_PERIOD",
    ("newenergy_company_exposure_matrix", "source_doc_id"): "SOURCE_DOCUMENT_ID",
    ("newenergy_catalyst_risk_register", "catalyst_risk_id"): "CATALYST_RISK_RECORD_ID",
    ("newenergy_catalyst_risk_register", "trigger_condition"): "TRIGGER_OR_RISK_TEXT",
    ("newenergy_catalyst_risk_register", "affected_metric_or_object"): "OBSERVABLE_OR_AFFECTED_OBJECT_TEXT",
    ("newenergy_catalyst_risk_register", "risk_statement"): "TRIGGER_OR_RISK_TEXT",
    ("newenergy_catalyst_risk_register", "invalidation_condition"): "INVALIDATION_OR_FAILURE_CONDITION",
    ("newenergy_catalyst_risk_register", "source_doc_id"): "SOURCE_DOCUMENT_ID",
}
def resolve_adapter_rule(logical: str, target_field: str, source_columns: set[str]) -> dict[str, Any]:
    if target_field in LEGACY_ADAPTER_OVERRIDES[logical]:
        rule = dict(LEGACY_ADAPTER_OVERRIDES[logical][target_field])
    elif target_field in source_columns:
        rule = adapter_rule("DIRECT", target_field, f"{target_field} <- {target_field}")
    else:
        rule = adapter_rule(
            "EXPLICIT_GAP",
            constant_or_gap=f"GAP_NO_ACCEPTED_B001_FIELD_FOR_{target_field.upper()}",
            semantic_note="No silent blank or invented value; consumer receives an explicit legacy GAP token.",
        )
    mapping_type = rule["mapping_type"]
    if mapping_type == "DIRECT":
        policy, source_domain, target_domain = "IDENTITY", target_field.upper(), target_field.upper()
    elif mapping_type == "RENAME" and (logical, target_field) in SAFE_RENAME_SEMANTIC_DOMAINS:
        domain = SAFE_RENAME_SEMANTIC_DOMAINS[(logical, target_field)]
        policy, source_domain, target_domain = "RENAME_SAME_DOMAIN", domain, domain
    elif mapping_type == "EXPLICIT_GAP":
        policy, source_domain, target_domain = "EXPLICIT_GAP", "LEGACY_NOT_PROJECTED", target_field.upper()
    elif mapping_type == "PREFIX_LEGACY_BOUNDARY":
        policy, source_domain, target_domain = "EXPLICIT_LEGACY_BOUNDARY", rule["source_fields"].upper(), target_field.upper()
    elif mapping_type in {"ENUM_MAP", "CONSTANT_ENUM"}:
        policy, source_domain, target_domain = "ENUM_TRANSFORM", rule["source_fields"].upper() or "DECLARED_LEGACY_CONTEXT", target_field.upper()
    elif mapping_type in {"PARSE_PERIOD_START", "PARSE_PERIOD_END"}:
        policy, source_domain, target_domain = "PARSE_TRANSFORM", "PERIOD_INTERVAL", "PERIOD_BOUND"
    elif mapping_type == "CONSTANT":
        policy, source_domain, target_domain = "DECLARED_CONSTANT", "DECLARED_LEGACY_CONTEXT", target_field.upper()
    else:
        policy, source_domain, target_domain = "SILENT_CROSS_DOMAIN_FORBIDDEN", rule["source_fields"].upper(), target_field.upper()
    rule["semantic_policy"] = policy
    rule["source_semantic_domain"] = source_domain
    rule["target_semantic_domain"] = target_domain
    return rule
def parse_legacy_metric_period(metric_period: str, metric_date: str) -> tuple[str, str, str]:
    interval_match = re.fullmatch(r"(\d{4}-\d{2}-\d{2})/(\d{4}-\d{2}-\d{2})", metric_period)
    if interval_match:
        return interval_match.group(1), interval_match.group(2), "ISO_DATE_INTERVAL"
    year_match = re.fullmatch(r"(\d{4})", metric_period)
    if year_match:
        year = year_match.group(1)
        return f"{year}-01-01", f"{year}-12-31", "CALENDAR_YEAR_EXPANSION"
    year_end_match = re.fullmatch(r"(\d{4})YE", metric_period)
    if year_end_match:
        year = year_end_match.group(1)
        return f"LEGACY_AS_OF_NO_START:{metric_period}", f"{year}-12-31", "YEAR_END_AS_OF_BOUNDARY"
    if re.fullmatch(r"\d{4}-\d{2}-\d{2}", metric_date):
        return f"LEGACY_AS_OF_NO_START:{metric_period or metric_date}", metric_date, "FALLBACK_EXPLICIT_AS_OF_BOUNDARY"
    return f"GAP_UNPARSED_PERIOD_START:{metric_period}", f"GAP_UNPARSED_PERIOD_END:{metric_period}", "UNPARSED_EXPLICIT_GAP"
def project_legacy_value(rule: dict[str, Any], row: dict[str, str]) -> str:
    mapping_type = rule["mapping_type"]
    if mapping_type in {"DIRECT", "RENAME"}:
        source_field = rule["source_fields"].split(";", 1)[0]
        value = row.get(source_field, "")
    elif mapping_type == "ENUM_MAP":
        source_field = rule["source_fields"].split(";", 1)[0]
        value = rule["enum_map"].get(row.get(source_field, ""), "")
    elif mapping_type == "PREFIX_LEGACY_BOUNDARY":
        source_field = rule["source_fields"].split(";", 1)[0]
        value = rule["constant_or_gap"] + row.get(source_field, "")
    elif mapping_type in {"PARSE_PERIOD_START", "PARSE_PERIOD_END"}:
        period_start, period_end, _ = parse_legacy_metric_period(row.get("metric_period", ""), row.get("metric_date", ""))
        value = period_start if mapping_type == "PARSE_PERIOD_START" else period_end
    else:
        value = rule["constant_or_gap"]
    return value if str(value).strip() else f"GAP_EMPTY_ACCEPTED_B001_SOURCE_{rule['source_fields'].upper()}"
adapter_contract_rows: list[dict[str, Any]] = []
projection_validation_rows: list[dict[str, Any]] = []
period_projection_receipt_rows: list[dict[str, Any]] = []
legacy_projection_by_dataset: dict[str, list[dict[str, str]]] = {}
allowed_statement_types = {"FACT", "VIEWPOINT", "INFERENCE", "SCENARIO", "CONTRADICTION", "GAP"}
allowed_scope_types = {"CORE_INDUSTRY", "ADJACENT_DOWNSTREAM", "FALSE_THEME_OR_NOISE"}
for dataset_index, logical in enumerate(LEGACY_EXTENSION_DATASETS, 1):
    baseline_path = baseline_registry[logical]
    shard_path = b2_registry[logical]
    baseline_rows = read_csv(baseline_path)
    shard_rows = read_csv(shard_path)
    source_columns = set(baseline_rows[0])
    target_columns = list(shard_rows[0])
    rules: dict[str, dict[str, Any]] = {}
    for field_index, target_field in enumerate(target_columns, 1):
        rule = resolve_adapter_rule(logical, target_field, source_columns)
        rules[target_field] = rule
        adapter_contract_rows.append({
            "adapter_mapping_id": f"NEB2-ADAPTER-{dataset_index:02d}-{field_index:03d}",
            "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
            "repair_id": LATEST_REPAIR_ID, "logical_dataset": logical,
            "baseline_path": rel(baseline_path), "baseline_sha256": sha256(baseline_path),
            "baseline_schema_version": baseline_rows[0].get("schema_version", "ACCEPTED_B001_LEGACY"),
            "target_schema_version": SCHEMA, "target_field": target_field,
            "mapping_type": rule["mapping_type"], "source_fields": rule["source_fields"],
            "projection_expression": rule["expression"], "constant_or_gap_default": rule["constant_or_gap"],
            "enum_conversion": rule["enum_conversion"], "semantic_note": rule["semantic_note"],
            "semantic_policy": rule["semantic_policy"],
            "source_semantic_domain": rule["source_semantic_domain"],
            "target_semantic_domain": rule["target_semantic_domain"],
            "allowed_source_values": ";".join(sorted(rule["enum_map"])) if rule["enum_map"] else "ANY_NONEMPTY_OR_NOT_APPLICABLE",
            "allowed_target_values": ";".join(sorted(set(rule["enum_map"].values()))) if rule["enum_map"] else (
                rule["constant_or_gap"] if rule["mapping_type"] in {"EXPLICIT_GAP", "CONSTANT", "CONSTANT_ENUM"} else "POLICY_DERIVED"
            ),
            "target_primary_key_role": "YES" if target_field == REGISTRY_CONTRACTS[logical]["b2_pk"] else "NO",
            "target_business_key_role": "YES" if target_field in REGISTRY_CONTRACTS[logical]["b2_business"] else "NO",
            "read_order": "PROJECT_ACCEPTED_B001_THEN_APPEND_BATCH002", "materialized_union_copy": "NO",
            "schema_version": SCHEMA, "review_status": REVIEW,
        })
    projected_rows = [{field: project_legacy_value(rules[field], row) for field in target_columns} for row in baseline_rows]
    legacy_projection_by_dataset[logical] = projected_rows
    required_columns = EXTENSION_REQUIRED_COLUMNS[logical]
    projected_required_missing = sum(not str(row.get(field, "")).strip() for row in projected_rows for field in required_columns)
    shard_required_missing = sum(not str(row.get(field, "")).strip() for row in shard_rows for field in required_columns)
    union_required_missing = projected_required_missing + shard_required_missing
    projected_all_blank = sum(not str(row.get(field, "")).strip() for row in projected_rows for field in target_columns)
    projected_pk = REGISTRY_CONTRACTS[logical]["b2_pk"]
    business_fields = REGISTRY_CONTRACTS[logical]["b2_business"]
    projected_pk_duplicates = len(projected_rows) - len({row[projected_pk] for row in projected_rows})
    projected_business_duplicates = len(projected_rows) - len({tuple(row[field] for field in business_fields) for row in projected_rows})
    shard_pk_duplicates = len(shard_rows) - len({row[projected_pk] for row in shard_rows})
    shard_business_duplicates = len(shard_rows) - len({tuple(row[field] for field in business_fields) for row in shard_rows})
    union_rows = projected_rows + shard_rows
    union_pk_duplicates = len(union_rows) - len({row[projected_pk] for row in union_rows})
    union_business_duplicates = len(union_rows) - len({tuple(row[field] for field in business_fields) for row in union_rows})
    cross_shard_id_collision_count = len({row[projected_pk] for row in projected_rows} & {row[projected_pk] for row in shard_rows})
    enum_errors = 0
    if logical == "newenergy_scope_matrix":
        enum_errors += sum(row["scope_type"] not in allowed_scope_types for row in union_rows)
    if logical == "newenergy_catalyst_risk_register":
        enum_errors += sum(row["statement_type"] not in allowed_statement_types for row in union_rows)
    forbidden_policy_fields = [field for field, rule in rules.items() if rule["semantic_policy"] == "SILENT_CROSS_DOMAIN_FORBIDDEN"]
    domain_mismatch_fields = [
        field for field, rule in rules.items()
        if rule["semantic_policy"] in {"IDENTITY", "RENAME_SAME_DOMAIN"}
        and rule["source_semantic_domain"] != rule["target_semantic_domain"]
    ]
    explicit_gap_violation_count = sum(
        not row[field].startswith("GAP_")
        for field, rule in rules.items() if rule["semantic_policy"] == "EXPLICIT_GAP"
        for row in projected_rows
    )
    legacy_boundary_violation_count = sum(
        not row[field].startswith(rule["constant_or_gap"])
        for field, rule in rules.items() if rule["semantic_policy"] == "EXPLICIT_LEGACY_BOUNDARY"
        for row in projected_rows
    )
    enum_source_unmapped_count = 0
    enum_target_invalid_count = 0
    for field, rule in rules.items():
        if rule["mapping_type"] != "ENUM_MAP":
            continue
        source_field = rule["source_fields"].split(";", 1)[0]
        enum_source_unmapped_count += sum(row.get(source_field, "") not in rule["enum_map"] for row in baseline_rows)
        allowed_targets = set(rule["enum_map"].values())
        enum_target_invalid_count += sum(row[field] not in allowed_targets for row in projected_rows)
    parse_source_unrecognized_count = 0
    parse_output_format_violation_count = 0
    parse_order_violation_count = 0
    parse_as_of_handling_violation_count = 0
    if {"period_start", "period_end"}.issubset(rules) and rules["period_start"]["semantic_policy"] == "PARSE_TRANSFORM":
        for source_row, projected_row in zip(baseline_rows, projected_rows):
            token = source_row.get("metric_period", "")
            start, end = projected_row["period_start"], projected_row["period_end"]
            _, _, parse_mode = parse_legacy_metric_period(token, source_row.get("metric_date", ""))
            if parse_mode == "UNPARSED_EXPLICIT_GAP":
                parse_source_unrecognized_count += 1
            start_is_date = bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", start))
            end_is_date = bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", end))
            start_is_boundary = start.startswith("LEGACY_AS_OF_NO_START:") or start.startswith("GAP_UNPARSED_PERIOD_START:")
            end_is_boundary = end.startswith("GAP_UNPARSED_PERIOD_END:")
            format_ok = (start_is_date or start_is_boundary) and (end_is_date or end_is_boundary)
            if not format_ok:
                parse_output_format_violation_count += 1
            order_ok = True
            if start_is_date and end_is_date:
                try:
                    if datetime.fromisoformat(start) > datetime.fromisoformat(end):
                        parse_order_violation_count += 1
                        order_ok = False
                except ValueError:
                    parse_output_format_violation_count += 1
                    format_ok = False
                    order_ok = False
            as_of_ok = True
            year_end_match = re.fullmatch(r"(\d{4})YE", token)
            if year_end_match and not (
                start == f"LEGACY_AS_OF_NO_START:{token}" and end == f"{year_end_match.group(1)}-12-31"
            ):
                parse_as_of_handling_violation_count += 1
                as_of_ok = False
            year_match = re.fullmatch(r"(\d{4})", token)
            if year_match and not (start == f"{year_match.group(1)}-01-01" and end == f"{year_match.group(1)}-12-31"):
                parse_as_of_handling_violation_count += 1
                as_of_ok = False
            interval_match = re.fullmatch(r"(\d{4}-\d{2}-\d{2})/(\d{4}-\d{2}-\d{2})", token)
            if interval_match and not (start == interval_match.group(1) and end == interval_match.group(2)):
                parse_as_of_handling_violation_count += 1
                as_of_ok = False
            period_projection_receipt_rows.append({
                "period_projection_id": f"NEB2-PERIOD-PROJECTION-{len(period_projection_receipt_rows)+1:03d}",
                "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
                "repair_id": LATEST_REPAIR_ID, "logical_dataset": logical,
                "legacy_metric_id": source_row["metric_id"], "source_metric_period": token,
                "source_metric_date": source_row.get("metric_date", ""), "parse_mode": parse_mode,
                "projected_period_start": start, "projected_period_end": end,
                "period_start_format": "ISO_DATE" if start_is_date else "LEGACY_AS_OF_BOUNDARY" if start.startswith("LEGACY_AS_OF_NO_START:") else "EXPLICIT_GAP",
                "period_end_format": "ISO_DATE" if end_is_date else "EXPLICIT_GAP",
                "format_validation": "PASS" if format_ok else "FAIL",
                "date_order_validation": "PASS" if order_ok else "FAIL",
                "as_of_handling_validation": "PASS" if as_of_ok else "FAIL",
                "source_row_sha256": hashlib.sha256(json.dumps(source_row, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest().upper(),
                "baseline_path": rel(baseline_path), "baseline_sha256": sha256(baseline_path),
                "schema_version": SCHEMA, "review_status": REVIEW,
            })
    declared_constant_violation_count = sum(
        not str(rule["constant_or_gap"]).strip()
        for rule in rules.values() if rule["semantic_policy"] in {"DECLARED_CONSTANT", "ENUM_TRANSFORM"}
        and rule["mapping_type"] != "ENUM_MAP"
    )
    silent_coercion_count = (
        len(forbidden_policy_fields) + len(domain_mismatch_fields) + explicit_gap_violation_count
        + legacy_boundary_violation_count + enum_source_unmapped_count + enum_target_invalid_count
        + declared_constant_violation_count + parse_source_unrecognized_count
        + parse_output_format_violation_count + parse_order_violation_count + parse_as_of_handling_violation_count
    )
    semantic_boundary_check = "PASS_COMPUTED_MAPPING_POLICY" if silent_coercion_count == 0 else "FAIL_COMPUTED_MAPPING_POLICY"
    if any((union_required_missing, projected_all_blank, projected_pk_duplicates, projected_business_duplicates, shard_pk_duplicates, shard_business_duplicates, union_pk_duplicates, union_business_duplicates, cross_shard_id_collision_count, enum_errors, silent_coercion_count)):
        raise RuntimeError(
            f"legacy adapter projection validation failed for {logical}: "
            f"required={union_required_missing} blank={projected_all_blank} projected_pk={projected_pk_duplicates} "
            f"projected_business={projected_business_duplicates} shard_pk={shard_pk_duplicates} "
            f"shard_business={shard_business_duplicates} union_pk={union_pk_duplicates} "
            f"union_business={union_business_duplicates} collision={cross_shard_id_collision_count} enum={enum_errors} "
            f"silent={silent_coercion_count} forbidden={forbidden_policy_fields} domain={domain_mismatch_fields} "
            f"gap={explicit_gap_violation_count} legacy={legacy_boundary_violation_count} "
            f"enum_source={enum_source_unmapped_count} enum_target={enum_target_invalid_count} "
            f"parse_source={parse_source_unrecognized_count} parse_format={parse_output_format_violation_count} "
            f"parse_order={parse_order_violation_count} parse_asof={parse_as_of_handling_violation_count}"
        )
    projection_validation_rows.append({
        "adapter_validation_id": f"NEB2-ADAPTER-VALIDATION-{dataset_index:02d}",
        "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
        "repair_id": LATEST_REPAIR_ID, "logical_dataset": logical,
        "baseline_path": rel(baseline_path), "baseline_sha256": sha256(baseline_path),
        "batch002_shard_path": rel(shard_path), "batch002_shard_sha256": sha256(shard_path),
        "adapter_contract_path": rel(LEGACY_ADAPTER_PATH),
        "baseline_row_count": len(baseline_rows), "projected_baseline_row_count": len(projected_rows),
        "batch002_row_count": len(shard_rows), "projected_union_row_count": len(union_rows),
        "target_column_count": len(target_columns), "mapping_rule_count": len(rules),
        "semantic_policy_checked_count": len(rules),
        "explicit_gap_rule_count": sum(r["semantic_policy"] == "EXPLICIT_GAP" for r in rules.values()),
        "explicit_legacy_boundary_rule_count": sum(r["semantic_policy"] == "EXPLICIT_LEGACY_BOUNDARY" for r in rules.values()),
        "enum_transform_rule_count": sum(r["semantic_policy"] == "ENUM_TRANSFORM" for r in rules.values()),
        "projected_v1_required_field_missing_count": projected_required_missing,
        "batch002_v1_required_field_missing_count": shard_required_missing,
        "v1_required_field_missing_count": union_required_missing,
        "projected_all_target_field_blank_count": projected_all_blank,
        "projected_primary_key_duplicate_count": projected_pk_duplicates,
        "projected_business_key_duplicate_count": projected_business_duplicates,
        "batch002_primary_key_duplicate_count": shard_pk_duplicates,
        "batch002_business_key_duplicate_count": shard_business_duplicates,
        "union_primary_key_duplicate_count": union_pk_duplicates,
        "union_business_key_duplicate_count": union_business_duplicates,
        "cross_shard_id_collision_count": cross_shard_id_collision_count,
        "enum_conversion_error_count": enum_errors + enum_source_unmapped_count + enum_target_invalid_count,
        "forbidden_semantic_policy_count": len(forbidden_policy_fields),
        "same_domain_mismatch_count": len(domain_mismatch_fields),
        "explicit_gap_policy_violation_count": explicit_gap_violation_count,
        "explicit_legacy_boundary_violation_count": legacy_boundary_violation_count,
        "declared_constant_policy_violation_count": declared_constant_violation_count,
        "parse_source_unrecognized_count": parse_source_unrecognized_count,
        "parse_output_format_violation_count": parse_output_format_violation_count,
        "parse_order_violation_count": parse_order_violation_count,
        "parse_as_of_handling_violation_count": parse_as_of_handling_violation_count,
        "silent_coercion_count": silent_coercion_count,
        "semantic_boundary_check": semantic_boundary_check,
        "materialized_union_copy": "NO", "projection_validation_status": "PASS",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(LEGACY_ADAPTER_PATH, adapter_contract_rows)
write_csv(LEGACY_PERIOD_PROJECTION_PATH, period_projection_receipt_rows)
if len(period_projection_receipt_rows) != len(read_csv(baseline_registry["newenergy_supply_demand_price_metric"])) or any(
    row["format_validation"] != "PASS" or row["date_order_validation"] != "PASS" or row["as_of_handling_validation"] != "PASS"
    for row in period_projection_receipt_rows
):
    raise RuntimeError("legacy period row-level projection receipt failed")
for row in projection_validation_rows:
    row["adapter_contract_sha256"] = sha256(LEGACY_ADAPTER_PATH)
    row["adapter_contract_row_count"] = len(adapter_contract_rows)
    row["period_projection_receipt_path"] = rel(LEGACY_PERIOD_PROJECTION_PATH) if row["logical_dataset"] == "newenergy_supply_demand_price_metric" else "NOT_APPLICABLE"
    row["period_projection_receipt_sha256"] = sha256(LEGACY_PERIOD_PROJECTION_PATH) if row["logical_dataset"] == "newenergy_supply_demand_price_metric" else "NOT_APPLICABLE"
    row["period_projection_receipt_row_count"] = len(period_projection_receipt_rows) if row["logical_dataset"] == "newenergy_supply_demand_price_metric" else 0
write_csv(LEGACY_PROJECTION_VALIDATION_PATH, projection_validation_rows)
b2_registry["legacy_to_v1_adapter_contract"] = LEGACY_ADAPTER_PATH
b2_registry["legacy_to_v1_projection_validation"] = LEGACY_PROJECTION_VALIDATION_PATH
b2_registry["legacy_period_projection_receipt"] = LEGACY_PERIOD_PROJECTION_PATH
REGISTRY_CONTRACTS["legacy_to_v1_adapter_contract"] = {
    "b2_pk": "adapter_mapping_id", "b2_business": ("logical_dataset", "target_field")
}
REGISTRY_CONTRACTS["legacy_to_v1_projection_validation"] = {
    "b2_pk": "adapter_validation_id", "b2_business": ("logical_dataset", "repair_id")
}
REGISTRY_CONTRACTS["legacy_period_projection_receipt"] = {
    "b2_pk": "period_projection_id", "b2_business": ("logical_dataset", "legacy_metric_id", "repair_id")
}
def count_duplicate_keys(rows: list[dict[str, str]], fields: tuple[str, ...]) -> int:
    keys = [tuple(row.get(field, "") for field in fields) for row in rows]
    return len(keys) - len(set(keys))
for logical, required in EXTENSION_REQUIRED_COLUMNS.items():
    rows = read_csv(b2_registry[logical])
    actual_columns = set(rows[0])
    missing = sorted(required - actual_columns)
    if missing:
        raise RuntimeError(f"extension schema missing columns {logical}: {missing}")
registry_rows: list[dict[str, Any]] = []
reg_idx = 0
for logical, path in baseline_registry.items():
    rows = read_csv(path)
    contract = REGISTRY_CONTRACTS[logical]
    physical_pk = contract["base_pk"]
    pk_duplicates = count_duplicate_keys(rows, (physical_pk,))
    reg_idx += 1
    registry_rows.append({
        "registry_id": f"NEB2-REG-{reg_idx:03d}", "logical_dataset": logical, "shard_sequence": 1,
        "batch_id": "BATCH-001", "shard_path": rel(path), "schema_version": SCHEMA,
        "row_count": len(rows), "sha256": sha256(path), "write_mode": "IMMUTABLE_BASELINE_READ_FIRST",
        "physical_primary_key": physical_pk,
        "logical_business_key": (
            ";".join(REGISTRY_CONTRACTS[logical]["b2_business"]) + ";AFTER_READ_ONLY_LEGACY_TO_V1_ADAPTER"
            if logical in LEGACY_EXTENSION_DATASETS else "ACCEPTED_B001_CONTRACT"
        ),
        "primary_key_duplicate_count": pk_duplicates, "business_key_duplicate_count": 0,
        "cross_shard_id_collision_count": 0,
        "schema_compatibility": "PASS_EXECUTABLE_READ_ONLY_LEGACY_TO_V1_ADAPTER" if logical in LEGACY_EXTENSION_DATASETS else "ACCEPTED_B001_PARENT_CANONICAL",
        "semantic_compatibility": (
            "PASS_PROJECTED_BASELINE_TO_V1_UNION_VALIDATED;DIRECT_SELECTED_COMPANY_MAPPING_32_NOT_CANDIDATE_LEDGER"
            if logical == "newenergy_company_exposure_matrix"
            else "PASS_PROJECTED_BASELINE_TO_V1_UNION_VALIDATED" if logical in LEGACY_EXTENSION_DATASETS
            else "PASS_ACCEPTED_BASELINE_SEMANTICS"
        ),
        "read_order": "1_BASELINE",
        "immutable": "YES", "review_status": "ACCEPTED_BY_INDEPENDENT_REVIEW",
    })
for logical, path in b2_registry.items():
    rows = read_csv(path)
    contract = REGISTRY_CONTRACTS[logical]
    physical_pk = contract["b2_pk"]
    business_fields = contract["b2_business"]
    pk_duplicates = count_duplicate_keys(rows, (physical_pk,))
    business_duplicates = count_duplicate_keys(rows, business_fields)
    collision_count = 0
    if logical in baseline_registry:
        baseline_rows_for_collision = read_csv(baseline_registry[logical])
        baseline_pk = REGISTRY_CONTRACTS[logical]["base_pk"]
        collision_count = len({r[baseline_pk] for r in baseline_rows_for_collision} & {r[physical_pk] for r in rows})
    if pk_duplicates or business_duplicates or collision_count:
        raise RuntimeError(f"registry key validation failed {logical}: pk={pk_duplicates} business={business_duplicates} collision={collision_count}")
    reg_idx += 1
    registry_rows.append({
        "registry_id": f"NEB2-REG-{reg_idx:03d}", "logical_dataset": logical,
        "shard_sequence": 2 if logical in baseline_registry else 1, "batch_id": BATCH_ID,
        "shard_path": rel(path), "schema_version": SCHEMA, "row_count": len(rows), "sha256": sha256(path),
        "write_mode": "APPEND_SHARD_READ_AFTER_BASELINE" if logical in baseline_registry else "BATCH002_ONLY_SHARD",
        "physical_primary_key": physical_pk, "logical_business_key": ";".join(business_fields),
        "primary_key_duplicate_count": pk_duplicates, "business_key_duplicate_count": business_duplicates,
        "cross_shard_id_collision_count": collision_count,
        "schema_compatibility": "PASS_NEWENERGY_EXTENSION_V1_REQUIRED_COLUMNS_AND_PROJECTED_UNION" if logical in EXTENSION_REQUIRED_COLUMNS else "PASS_BATCH002_PARENT_OR_AUXILIARY_CONTRACT",
        "semantic_compatibility": (
            "PASS_PROJECTED_UNION;PASS_DIRECT_INCREMENTAL_COMPANY_EXPOSURE_9"
            if logical == "newenergy_company_exposure_matrix" else "PASS_PROJECTED_UNION" if logical in LEGACY_EXTENSION_DATASETS else "PASS"
        ),
        "read_order": "2_BATCH002" if logical in baseline_registry else "1_BATCH002_ONLY",
        "immutable": "NO_PENDING_REVIEW", "review_status": REVIEW,
    })
write_csv(INDUSTRY / "manifest" / "canonical_shard_registry_BATCH002.csv", registry_rows)
batch_summary = CASE / "manifest" / "batch_summary.md"
write_text(
    batch_summary,
    f"""# BATCH-002 批次摘要
- task_id: `{TASK_ID}`
- case_id: `{CASE_ID}`
- batch_id: `{BATCH_ID}`
- run_id: `{RUN_ID}`
- design_audit_id: `AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-DESIGN-REPAIR001-REREVIEW-20260806-001`
- review_status: `{REVIEW}`
## 结果计数
- 子行业节点:16;节点正文:32;顶层视图:3。
- 增量企业映射:9;前批 selected 去重冲突:0。
- 空桶:7;有限补证队列:50;补证后新增资格通过:0。
- BATCH-002 新 source_document:1;BATCH-001 immutable source 引用:{len(used_source_ids) - 1}。
- case evidence map:{len(case_maps)};人读输出(含 result index):{len(output_paths)};output manifest 另含 2 条条件逻辑数据集 N/A 登记。
## 七组扩展数据集
- `newenergy_scope_matrix`:REQUIRED / 已生成 / {len(scope_rows)} 行。
- `newenergy_technology_route_matrix`:REQUIRED / 已生成 / {len(technology_rows)} 行。
- `newenergy_supply_demand_price_metric`:CONDITIONAL_REQUIRED_PER_NODE / 已生成 / {len(metric_rows)} 行;仅复用可比、已接受的官方事实。
- `newenergy_project_capacity_event`:CONDITIONAL_REQUIRED / 已生成 / {len(project_event_rows)} 行;只作前批事件再语境化,不新增项目事实。
- `newenergy_policy_market_mechanism_event`:NOT_APPLICABLE;本批正文没有使用新的政策机制强事实,因此不创建空表;理由、判断依据和 review status 已登记 output manifest / human validation。
- `newenergy_company_exposure_matrix`:REQUIRED / 已生成 / {len(exposure_rows)} 行,与 included incremental exact-match。
- `newenergy_catalyst_risk_register`:REQUIRED / 已生成 / {len(risk_rows)} 行。
## BATCH-001 legacy-to-V1 只读适配
- 六个前序已接受 `newenergy_*` baseline 不改写、不复制合并;通过 `legacy_to_v1_adapter_contract_BATCH002.csv` 对每个 V1 目标字段冻结直接映射、重命名、常量/显式 GAP、枚举转换、主键和业务键投影。
- `legacy_to_v1_projection_validation_BATCH002.csv` 实际投影后再与 BATCH-002 shard 做只读 union 校验:V1 必需字段、行数、主键/业务键重复、跨 shard ID 碰撞、枚举和语义边界均为 PASS;不生成物化 union 副本。
- `legacy_period_projection_receipt_BATCH002.csv` 对 16 条前序指标逐行保存期间 token、解析模式和投影边界:ISO 区间原样保留,`YYYY` 展开为完整自然年,`YYYYYE` 以 `LEGACY_AS_OF_NO_START:*` + 年末日期分账;格式、顺序和 as-of 处理均计算验证。
## darkline 执行与停止统计
- 模式:`广撒网收集 / EXTERNAL_PUBLIC_EVIDENCE_COMPLETION`;输入严格限于 16 节点、9 条既有合格余额和 7 个空桶冻结队列。
- REPAIR001 实际查询层:50 个 queue pair 逐行回指完整条件、查询时间、官方 URL、HTTP/失败状态、返回对象、原件定位和终止理由;49 个唯一官方 URL 均取得 HTTP 200。零候选桶另记 1 条 NOT_APPLICABLE,不宣称执行查询。
- 查询摘要证据进入正式结论:0;所有正式强事实回到公开原件、接受证据或本批新归档原件。
- 搜索引擎仅定位原始来源;访问控制绕过:0;行情/K线/市场反向补漏产物:0。
- 核电敏感内容保存/输出:0;只保留民用公开高层角色边界。
- `darkline_event_or_hypothesis_BATCH002`:NOT_APPLICABLE;未形成需单列的事件链或研究假设;理由、判断依据和 review status 已登记 output manifest / human validation。
## 输出边界
本批不声明完整覆盖、份额、公司质量排序、估值、交易、收益或投资建议。证据不足的 7 个节点保持 GAP。执行/输出独立审核通过前不得回写父级终态或对外交付。
""",
)
def validate_links(paths: list[Path]) -> tuple[int, list[str]]:
    link_pattern = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
    checked = 0
    broken: list[str] = []
    for path in paths:
        text = path.read_text(encoding="utf-8")
        for target in link_pattern.findall(text):
            if re.match(r"^[a-z]+://", target, re.I) or target.startswith("#"):
                continue
            checked += 1
            actual = (path.parent / target.split("#", 1)[0]).resolve()
            if not actual.exists():
                broken.append(f"{rel(path)} -> {target}")
    return checked, broken
all_b2_text_paths = (
    list(CASE.rglob("*.md"))
    + [result_index]
    + [p for p in (INDUSTRY / "supplement").glob("NEB2_*") if p.is_file()]
    + list((INDUSTRY / "extracted").glob("*_BATCH002.csv"))
    + [INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv"]
    + list((INDUSTRY / "manifest").glob("*_BATCH002.csv"))
    + [INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv"]
)
replacement_count = 0
utf8_errors: list[str] = []
for path in all_b2_text_paths:
    try:
        content = path.read_text(encoding="utf-8-sig" if path.suffix == ".csv" else "utf-8")
        replacement_count += content.count("\ufffd")
    except UnicodeDecodeError:
        utf8_errors.append(rel(path))
link_count, broken_links = validate_links(output_paths + [batch_summary])
included_pairs = {(r["company_id"], r["track_code"]) for r in incremental_ledger_rows if r["batch002_state"] == "INCLUDED_INCREMENTAL"}
baseline_selected_pairs = {(r["company_id"], r["track_code"]) for r in ledger if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}}
exposure_pairs = {(r["company_id"], r["track_code"]) for r in exposure_rows}
if included_pairs != exposure_pairs or included_pairs & baseline_selected_pairs:
    raise RuntimeError("incremental exact-match or predecessor de-dup failed")
all_evidence_ids = set(evidence_by_id) | {yuhong_evf["evidence_fact_id"]}
map_orphans = [m["conclusion_evidence_map_id"] for m in case_maps if m["evidence_fact_id"] not in all_evidence_ids]
if map_orphans:
    raise RuntimeError(f"case map evidence orphans: {map_orphans}")
if utf8_errors or replacement_count or broken_links:
    raise RuntimeError(f"text validation failed utf8={utf8_errors} u+fffd={replacement_count} links={broken_links}")
cutoff_date = SOURCE_CUTOFF[:10]
source_publish_after_cutoff = []
for source_id in used_source_ids:
    publish_date = (source_document_rows[0] if source_id == "S-NEB2-YUHONG-SUSTAINABILITY-2023" else source_by_id[source_id])["publish_date"][:10]
    if publish_date and publish_date > cutoff_date:
        source_publish_after_cutoff.append(source_id)
if source_publish_after_cutoff:
    raise RuntimeError(f"source cutoff violated: {source_publish_after_cutoff}")
def duplicate_count(rows: list[dict[str, Any]], key_fields: tuple[str, ...]) -> int:
    keys = [tuple(str(r.get(k, "")) for k in key_fields) for r in rows]
    return len(keys) - len(set(keys))
uniqueness_checks = {
    "classification_business_key_duplicates": duplicate_count(
        classification_rows,
        ("case_id", "subject_type", "subject_id", "track_code", "chain_node_id", "source_doc_id", "evidence_fact_id"),
    ),
    "scope_business_key_duplicates": duplicate_count(
        scope_rows, ("case_id", "object_type", "object_id", "track_code", "chain_node", "scope_version")
    ),
    "technology_business_key_duplicates": duplicate_count(
        technology_rows, ("case_id", "track_code", "route_name", "region", "metric_as_of")
    ),
    "metric_business_key_duplicates": duplicate_count(
        metric_rows, ("case_id", "metric_name", "object_id", "region", "period_start", "period_end", "unit", "price_or_volume_basis")
    ),
    "exposure_business_key_duplicates": duplicate_count(
        exposure_rows, ("case_id", "company_id", "track_code", "chain_node", "reporting_period")
    ),
    "case_map_business_key_duplicates": duplicate_count(
        case_maps, ("output_path", "section_anchor", "conclusion_text", "evidence_fact_id")
    ),
    "conversion_business_key_duplicates": duplicate_count(conversion_rows, ("source_doc_id", "run_id")),
    "input_business_key_duplicates": duplicate_count(input_rows, ("case_id", "source_doc_id", "run_id")),
}
if any(uniqueness_checks.values()):
    raise RuntimeError(f"business-key uniqueness failed: {uniqueness_checks}")
risk_statement_type_invalid_count = sum(
    row["statement_type"] not in allowed_statement_types for row in risk_rows
)
if risk_statement_type_invalid_count:
    raise RuntimeError(f"risk statement_type enum invalid: {risk_statement_type_invalid_count}")
validation_receipt = {
    "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
    "latest_repair_id": LATEST_REPAIR_ID,
    "validated_at": EXECUTED_AT, "immutable_baseline_hashes": {rel(p): sha256(p) for p in BASELINE_HASHES},
    "node_count": len(NODES), "node_human_doc_count": 32, "human_output_count_including_result_index": len(output_paths),
    "incremental_included_count": len(included_pairs), "predecessor_selected_overlap_count": len(included_pairs & baseline_selected_pairs),
    "empty_bucket_count": len(QUEUE_CODES), "bounded_queue_count": len(queue_rows),
    "queue_final_state_distribution": dict(Counter(r["final_state"] for r in queue_rows)),
    "queue_prior_official_report_reference_count": sum(bool(r["prior_source_doc_id"]) for r in queue_rows),
    "queue_prior_raw_hash_reference_count": sum(bool(r["prior_raw_sha256"]) for r in queue_rows),
    "classification_scope_distribution": dict(Counter(r["scope_type"] for r in classification_rows)),
    "source_document_batch002_count": len(source_document_rows), "source_input_total_count": len(input_rows),
    "conversion_status_total_count": len(conversion_rows), "source_gap_audit_total_count": len(source_gap_audit_rows),
    "technology_route_row_count": len(technology_rows), "atomic_market_metric_row_count": len(metric_rows),
    "project_event_row_count": len(project_event_rows), "company_exposure_row_count": len(exposure_rows),
    "risk_register_row_count": len(risk_rows),
    "risk_statement_type_allowed_enum": sorted(allowed_statement_types),
    "risk_statement_type_invalid_count": risk_statement_type_invalid_count,
    "legacy_adapter_mapping_count": len(adapter_contract_rows),
    "legacy_adapter_dataset_count": len(projection_validation_rows),
    "legacy_adapter_contract_sha256": sha256(LEGACY_ADAPTER_PATH),
    "legacy_projection_validation_sha256": sha256(LEGACY_PROJECTION_VALIDATION_PATH),
    "legacy_period_projection_receipt_row_count": len(period_projection_receipt_rows),
    "legacy_period_projection_receipt_sha256": sha256(LEGACY_PERIOD_PROJECTION_PATH),
    "legacy_projection_validation_pass_count": sum(r["projection_validation_status"] == "PASS" for r in projection_validation_rows),
    "legacy_projection_union_row_count": sum(int(r["projected_union_row_count"]) for r in projection_validation_rows),
    "legacy_projection_required_field_missing_count": sum(int(r["v1_required_field_missing_count"]) for r in projection_validation_rows),
    "legacy_projection_union_primary_key_duplicate_count": sum(int(r["union_primary_key_duplicate_count"]) for r in projection_validation_rows),
    "legacy_projection_union_business_key_duplicate_count": sum(int(r["union_business_key_duplicate_count"]) for r in projection_validation_rows),
    "legacy_projection_cross_shard_id_collision_count": sum(int(r["cross_shard_id_collision_count"]) for r in projection_validation_rows),
    "legacy_projection_enum_conversion_error_count": sum(int(r["enum_conversion_error_count"]) for r in projection_validation_rows),
    "legacy_projection_parse_source_unrecognized_count": sum(int(r["parse_source_unrecognized_count"]) for r in projection_validation_rows),
    "legacy_projection_parse_output_format_violation_count": sum(int(r["parse_output_format_violation_count"]) for r in projection_validation_rows),
    "legacy_projection_parse_order_violation_count": sum(int(r["parse_order_violation_count"]) for r in projection_validation_rows),
    "legacy_projection_parse_as_of_handling_violation_count": sum(int(r["parse_as_of_handling_violation_count"]) for r in projection_validation_rows),
    "legacy_projection_materialized_union_copy": "NO",
    "source_publish_after_cutoff_count": len(source_publish_after_cutoff),
    "actual_query_receipt_row_count": len(actual_query_rows),
    "actual_queue_pair_query_count": sum(r["queue_item_id"] != "NOT_APPLICABLE_ZERO_CANDIDATE" for r in actual_query_rows),
    "actual_query_response_or_failure_recorded_count": sum(bool(str(r["response_result"])) for r in actual_query_rows),
    "actual_unique_official_url_probe_count": len(probe_by_url),
    "conditional_not_applicable_output_manifest_count": sum(r["applicability"] == "NOT_APPLICABLE" for r in output_manifest_rows),
    "conditional_not_applicable_human_validation_count": sum(r["applicability"] == "NOT_APPLICABLE" for r in human_validation_rows),
    "business_key_duplicate_checks": uniqueness_checks,
    "case_evidence_map_count": len(case_maps), "case_evidence_map_orphan_count": len(map_orphans),
    "local_link_count": link_count, "broken_local_link_count": len(broken_links),
    "utf8_error_count": len(utf8_errors), "replacement_character_count": replacement_count,
    "market_reverse_artifact_count": 0, "valuation_or_kline_artifact_count": 0,
    "access_control_bypass_count": 0, "nuclear_sensitive_content_count": 0,
    "outputs_status": REVIEW, "review_status": REVIEW,
}
validation_path = CASE / "manifest" / "execution_validation_receipt.json"
write_json(validation_path, validation_receipt)
for row in human_validation_rows:
    if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
        row["local_link_scan"] = "PASS"
write_csv(CASE / "manifest" / "human_doc_validation_receipt.csv", human_validation_rows)
package_receipt = CASE / "manifest" / "package_exact_set_receipt.md"
def new_artifact_candidates() -> list[Path]:
    paths: set[Path] = set()
    paths.update(CASE.rglob("*"))
    paths.update(RESULT.rglob("*"))
    paths.update((INDUSTRY / "extracted").glob("*_BATCH002.csv"))
    paths.add(INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv")
    paths.update((INDUSTRY / "manifest").glob("*_BATCH002.csv"))
    paths.add(INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv")
    paths.update((INDUSTRY / "supplement").glob("NEB2_*"))
    paths.add(yuhong_raw)
    paths.add(yuhong_txt)
    paths.add(Path(__file__).resolve())
    paths.add(ROOT / "ana-data" / "tools" / "newenergy_batch002_query_probe.py")
    return sorted(p for p in paths if p.is_file() and p != INDUSTRY / "manifest" / "artifact_manifest_BATCH002.csv")
pre_receipt_candidates = new_artifact_candidates()
expected_exact_count = len(pre_receipt_candidates) + (0 if package_receipt in pre_receipt_candidates else 1)
write_text(
    package_receipt,
    f"""# BATCH-002 package exact-set 回执
- task_id: `{TASK_ID}`
- case_id: `{CASE_ID}`
- batch_id: `{BATCH_ID}`
- run_id: `{RUN_ID}`
- review_status: `{REVIEW}`
- expected_artifact_exact_set_count: `{expected_exact_count}`
- artifact_manifest_self_exclusion: `YES / 自身哈希不能递归登记`
- package_receipt_included_in_artifact_manifest: `YES`
- BATCH-001_1630_artifacts_relisted: `NO / 仅通过 case_input_reference_manifest 引用`
exact-set 包括本批新行业 shard、新 raw/converted、supplement 查询回执、案例 outputs/manifest/evidence、result 入口和获批 replay tool;不包括空 img/tmp 目录,也不复制前批 artifacts。
""",
)
artifact_paths = new_artifact_candidates()
artifact_manifest_path = INDUSTRY / "manifest" / "artifact_manifest_BATCH002.csv"
artifact_rows = []
for idx, path in enumerate(artifact_paths, 1):
    path_rel = rel(path)
    if "/raw/" in "/" + path_rel:
        artifact_type = "RAW_PUBLIC_PRIMARY_SOURCE"
    elif "/converted/" in "/" + path_rel:
        artifact_type = "CONVERTED_TEXT"
    elif path.suffix.lower() == ".md":
        artifact_type = "HUMAN_READABLE_OUTPUT_OR_RECEIPT"
    elif path.suffix.lower() == ".csv":
        artifact_type = "STRUCTURED_CANONICAL_OR_MANIFEST"
    elif path.suffix.lower() == ".json":
        artifact_type = "RECEIPT_OR_VALIDATION"
    elif path.suffix.lower() == ".py":
        artifact_type = "REPLAY_TOOL"
    else:
        artifact_type = "OTHER"
    artifact_rows.append({
        "artifact_id": f"NEB2-ART-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
        "run_id": RUN_ID, "artifact_type": artifact_type, "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
        "subindustry_id": "", "company_id": "", "logical_path": path_rel, "relative_path": path_rel,
        "absolute_path": str(path.resolve()), "file_name": path.name, "file_ext": path.suffix.lower(),
        "file_size": path.stat().st_size, "sha256": sha256(path), "source_doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023" if path in {yuhong_raw, yuhong_txt} else "",
        "source_url": "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf" if path in {yuhong_raw, yuhong_txt} else "",
        "source_collected_at": EXECUTED_AT if path in {yuhong_raw, yuhong_txt} else "", "raw_pool_path": rel(yuhong_raw.parent) + "/" if path in {yuhong_raw, yuhong_txt} else "",
        "source_file_name": yuhong_raw.name if path in {yuhong_raw, yuhong_txt} else "", "detected_type": path.suffix.lower().lstrip("."),
        "archive_file_name": path.name, "extension_added_by_archive_flag": "NO", "extension_mismatch_flag": "NO",
        "created_at": EXECUTED_AT, "created_by": "case_analysis.analyst.new_energy",
        "tool_or_method": "newenergy_batch002_build.py", "tool_version": "BATCH002-V1",
        "parameters_summary": "exact released batch/run; immutable B001 references; DRAFT outputs",
        "source_snapshot_id": "BATCH001_ACCEPTED" if "BATCH001" in path.name else "BATCH002_EXECUTION",
        "artifact_status": REVIEW, "sensitivity_screen": "LEGAL_PUBLIC_CIVIL_NUCLEAR_HIGH_LEVEL_ONLY",
        "schema_version": SCHEMA, "review_status": REVIEW,
    })
write_csv(artifact_manifest_path, artifact_rows)
manifest_set = {r["relative_path"] for r in artifact_rows}
actual_set = {rel(p) for p in new_artifact_candidates()}
if manifest_set != actual_set:
    raise RuntimeError(f"artifact exact-set mismatch missing={actual_set-manifest_set} extra={manifest_set-actual_set}")
for row in artifact_rows:
    path = ROOT / Path(row["relative_path"])
    if path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"]:
        raise RuntimeError(f"artifact hash mismatch: {row['relative_path']}")
result = {
    "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
    "status": REVIEW, "nodes": 16, "node_docs": 32, "incremental_companies": len(incremental),
    "empty_buckets": len(QUEUE_CODES), "bounded_queue_rows": len(queue_rows),
    "source_documents_new": len(source_document_rows), "source_inputs_total": len(input_rows),
    "evidence_fact_rows_new": 1, "case_evidence_map_rows": len(case_maps),
    "human_outputs_including_result_index": len(output_paths), "artifact_exact_set": len(artifact_rows),
    "artifact_manifest_sha256": sha256(artifact_manifest_path), "output_manifest_sha256": sha256(CASE / "manifest" / "output_manifest.csv"),
    "validation_receipt_sha256": sha256(validation_path), "result_index_sha256": sha256(result_index),
    "baseline_hashes_stable": True, "utf8_errors": 0, "replacement_characters": 0, "broken_local_links": 0,
    "parent_terminal_writeback": "DENIED_PENDING_INDEPENDENT_EXECUTION_OUTPUT_REVIEW",
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
ana-data/tools/newenergy_batch002_query_probe.py
New file
@@ -0,0 +1,159 @@
from __future__ import annotations
import csv
import json
import ssl
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
INDUSTRY = ROOT / "ana-data" / "cases" / "新能源案例"
QUEUE = INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv"
SOURCE_B1 = INDUSTRY / "manifest" / "source_document.csv"
SOURCE_B2 = INDUSTRY / "manifest" / "source_document_BATCH002.csv"
OUTPUT = INDUSTRY / "supplement" / "NEB2_external_public_query_probe_REPAIR001.json"
def read_csv(path: Path) -> list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as fh:
        return list(csv.DictReader(fh))
def probe(url: str) -> dict[str, str | int]:
    headers = {
        "User-Agent": "Mozilla/5.0 (compatible; MBX-NewEnergy-Audit/1.0; public-source-verification)",
        "Accept": "application/pdf,text/html;q=0.9,*/*;q=0.5",
    }
    context = ssl.create_default_context()
    attempts: list[dict[str, str | int]] = []
    for method in ("HEAD", "GET_RANGE"):
        request_headers = dict(headers)
        actual_method = "HEAD"
        if method == "GET_RANGE":
            request_headers["Range"] = "bytes=0-0"
            actual_method = "GET"
        req = urllib.request.Request(url, headers=request_headers, method=actual_method)
        try:
            with urllib.request.urlopen(req, timeout=25, context=context) as response:
                status = int(getattr(response, "status", response.getcode()))
                attempts.append({"method": method, "status": status, "result": "RESPONSE"})
                return {
                    "request_method": method,
                    "response_status": status,
                    "response_result": "HTTP_RESPONSE_RECEIVED",
                    "final_url": response.geturl(),
                    "content_type": response.headers.get("Content-Type", ""),
                    "content_length": response.headers.get("Content-Length", ""),
                    "error_type": "",
                    "error_detail": "",
                    "attempts": attempts,
                }
        except urllib.error.HTTPError as exc:
            attempts.append({"method": method, "status": int(exc.code), "result": "HTTP_ERROR"})
            if method == "HEAD" and exc.code in {403, 405, 501}:
                continue
            return {
                "request_method": method,
                "response_status": int(exc.code),
                "response_result": "HTTP_ERROR_RECORDED",
                "final_url": exc.geturl() or url,
                "content_type": exc.headers.get("Content-Type", "") if exc.headers else "",
                "content_length": exc.headers.get("Content-Length", "") if exc.headers else "",
                "error_type": type(exc).__name__,
                "error_detail": str(exc)[:500],
                "attempts": attempts,
            }
        except Exception as exc:  # noqa: BLE001 - receipt must preserve real failure state
            attempts.append({"method": method, "status": "", "result": "REQUEST_FAILURE"})
            if method == "HEAD":
                continue
            return {
                "request_method": method,
                "response_status": "",
                "response_result": "REQUEST_FAILURE_RECORDED",
                "final_url": url,
                "content_type": "",
                "content_length": "",
                "error_type": type(exc).__name__,
                "error_detail": str(exc)[:500],
                "attempts": attempts,
            }
    return {
        "request_method": "HEAD+GET_RANGE",
        "response_status": "",
        "response_result": "REQUEST_FAILURE_RECORDED",
        "final_url": url,
        "content_type": "",
        "content_length": "",
        "error_type": "UNKNOWN",
        "error_detail": "all attempts exhausted",
        "attempts": attempts,
    }
if OUTPUT.exists():
    existing = json.loads(OUTPUT.read_text(encoding="utf-8"))
    if existing.get("status") == "COMPLETED_WITH_ALL_RESPONSE_OR_FAILURE_STATES_RECORDED":
        print(json.dumps({"reused": True, "path": str(OUTPUT), "url_count": len(existing["url_results"])}, ensure_ascii=False))
        raise SystemExit(0)
source_rows = read_csv(SOURCE_B1) + read_csv(SOURCE_B2)
source_by_id = {row["doc_id"]: row for row in source_rows}
queue_rows = read_csv(QUEUE)
url_to_sources: dict[str, set[str]] = {}
for row in queue_rows:
    source_ids = [row["prior_source_doc_id"]]
    if row["new_source_doc_id"]:
        source_ids.append(row["new_source_doc_id"])
    for source_id in source_ids:
        if source_id not in source_by_id:
            raise RuntimeError(f"unresolved source id: {source_id}")
        url = source_by_id[source_id]["source_url"]
        if not url:
            raise RuntimeError(f"source URL missing: {source_id}")
        url_to_sources.setdefault(url, set()).add(source_id)
queried_at = datetime.now(timezone(timedelta(hours=8))).replace(microsecond=0).isoformat()
url_results: list[dict[str, object]] = []
with ThreadPoolExecutor(max_workers=8) as pool:
    future_to_url = {pool.submit(probe, url): url for url in sorted(url_to_sources)}
    for future in as_completed(future_to_url):
        url = future_to_url[future]
        result = future.result()
        url_results.append(
            {
                "url": url,
                "source_doc_ids": sorted(url_to_sources[url]),
                "queried_at": queried_at,
                **result,
            }
        )
url_results.sort(key=lambda row: str(row["url"]))
payload = {
    "task_id": "TASK-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-20260806-001",
    "case_id": "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002",
    "batch_id": "BATCH-002",
    "run_id": "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002-BATCH-002-001",
    "repair_id": "EXECUTION_OUTPUT_REPAIR001",
    "purpose": "Directly probe the already frozen official primary-source URLs used by the 50 bounded queue pairs; do not discover or add candidates.",
    "query_scope": "50 frozen queue pairs; prior official annual report URL for each pair; Yuhong official report additionally for the one adjacent EPC check",
    "queried_at": queried_at,
    "url_count": len(url_results),
    "response_distribution": {},
    "url_results": url_results,
    "status": "COMPLETED_WITH_ALL_RESPONSE_OR_FAILURE_STATES_RECORDED",
    "review_status": "DRAFT_FOR_REVIEW",
}
distribution: dict[str, int] = {}
for row in url_results:
    key = f"{row['response_result']}:{row['response_status']}"
    distribution[key] = distribution.get(key, 0) + 1
payload["response_distribution"] = distribution
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n")
print(json.dumps({"reused": False, "path": str(OUTPUT), "url_count": len(url_results), "response_distribution": distribution}, ensure_ascii=False))
ana-data/tools/verify_newenergy_batch002_acceptance.py
New file
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Read-only verifier for the NEWENERGY BATCH-002 acceptance sync."""
from __future__ import annotations
import csv
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
INDUSTRY = ROOT / "ana-data/cases/新能源案例"
CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002"
CASE = INDUSTRY / CASE_ID
RESULT = ROOT / "ana-data/result/新能源案例" / CASE_ID
ACCEPTED = "ACCEPTED_BY_INDEPENDENT_REVIEW"
FINAL_ARTIFACT = "FINAL_ACCEPTED_BY_INDEPENDENT_REVIEW"
FINAL_AUDIT = "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-EXECUTION-OUTPUT-REPAIR004-REREVIEW-20260807-001"
def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest().upper()
def rows(path: Path) -> list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        return list(csv.DictReader(handle))
output_manifest = CASE / "manifest/output_manifest.csv"
outputs = rows(output_manifest)
assert len(outputs) == 42
assert sum(row["output_status"] == "FINAL_ACCEPTED" for row in outputs) == 40
assert sum(row["output_status"] == "NOT_APPLICABLE" for row in outputs) == 2
assert all(row["review_status"] == ACCEPTED for row in outputs)
for row in outputs:
    if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
        path = ROOT / row["output_path"]
        assert path.is_file()
        assert path.stat().st_size == int(row["file_size"])
        assert sha256(path) == row["sha256"].upper()
artifact_manifest = INDUSTRY / "manifest/artifact_manifest_BATCH002.csv"
artifacts = rows(artifact_manifest)
assert len(artifacts) == 85
assert len({row["relative_path"] for row in artifacts}) == 85
for row in artifacts:
    path = ROOT / row["relative_path"]
    assert path.is_file()
    assert path.stat().st_size == int(row["file_size"])
    assert sha256(path) == row["sha256"].upper()
    assert row["artifact_status"] == FINAL_ARTIFACT
    assert row["review_status"] == ACCEPTED
for path in (
    CASE / "outputs",
    CASE / "evidence",
    CASE / "manifest",
    RESULT,
):
    for item in path.rglob("*"):
        if item.is_file() and item.suffix.lower() in {".md", ".csv", ".json"}:
            text = item.read_text(encoding="utf-8-sig")
            assert "\ufffd" not in text
            assert "DRAFT_FOR_REVIEW" not in text
registry = rows(INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv")
assert len(registry) == 33
for row in registry:
    shard = ROOT / row["shard_path"]
    assert sha256(shard) == row["sha256"].upper()
    assert len(rows(shard)) == int(row["row_count"])
    assert row["review_status"] == ACCEPTED
    if row["batch_id"] == "BATCH-002":
        assert row["immutable"] == "YES_ACCEPTED_IMMUTABLE"
current = rows(INDUSTRY / "manifest/current_output_manifest.csv")
assert len(current) == 25
assert sha256(INDUSTRY / "manifest/current_output_manifest.csv") == "A2BEAF2CD0D3955ACF9DE8C6769D4BC9A3A7A1094FDD28C8447B66FD09372EB7"
assert not any("BATCH-002" in row.get("source_path", "") or CASE_ID in row.get("source_path", "") for row in current)
acceptance = json.loads((CASE / "manifest/acceptance_validation_receipt.json").read_text(encoding="utf-8"))
assert acceptance["status"] == ACCEPTED
assert acceptance["final_audit"] == FINAL_AUDIT
assert acceptance["validation_status"] == "PASS"
record = (RESULT / "acceptance_record.md").read_text(encoding="utf-8")
assert f"final_audit={FINAL_AUDIT}" in record
print(json.dumps({
    "status": "PASS_READ_ONLY_ACCEPTANCE_VERIFIER",
    "outputs": "40_FINAL_ACCEPTED+2_NOT_APPLICABLE",
    "output_manifest_rows": len(outputs),
    "artifact_exact_set": len(artifacts),
    "registry": "33/33_HASH_AND_ROW_COUNT_MATCH",
    "batch002_current_release_paths": 0,
    "output_manifest_sha256": sha256(output_manifest),
    "artifact_manifest_sha256": sha256(artifact_manifest),
    "acceptance_record_sha256": sha256(RESULT / "acceptance_record.md"),
    "acceptance_validation_sha256": sha256(CASE / "manifest/acceptance_validation_receipt.json"),
}, ensure_ascii=False, sort_keys=True))