"""Congela (pre-registra) la hipotesis y el plan de analisis de un paper. Anti-HARKing (Hypothesizing After the Results are Known): el pre-registro fija la hipotesis y el plan de analisis ANTES de mirar los datos. Una vez congelado (``status: frozen``) es INMUTABLE: cualquier intento posterior de re-congelar con un contenido distinto se RECHAZA en vez de sobrescribir, de modo que no se puede "ajustar" la hipotesis a los resultados despues de verlos. Escribe/actualiza ``/preregistration.md`` con un frontmatter (``paper_slug``, ``frozen_at``, ``content_hash``, ``status``) y un cuerpo markdown DETERMINISTA derivado de ``(hypotheses, analysis_plan)``. Estilo dict-no-throw: NUNCA lanza; cualquier error previsible se captura y se devuelve como ``{"status": "error", "note": ...}``. """ import hashlib import os from datetime import datetime, timezone def _build_body(hypotheses: dict, analysis_plan: dict) -> str: """Construye el cuerpo markdown del pre-registro de forma DETERMINISTA. Mismo ``(hypotheses, analysis_plan)`` -> mismo cuerpo byte a byte. Las claves se ordenan alfabeticamente para no depender del orden de insercion del dict. """ lines = ["## Hypotheses", ""] for k in sorted(hypotheses.keys()): lines.append(f"- **{k}**: {hypotheses[k]}") lines.append("") lines.append("## Analysis plan") lines.append("") for k in sorted(analysis_plan.keys()): lines.append(f"- **{k}**: {analysis_plan[k]}") return "\n".join(lines) def _normalize(body: str) -> str: """Normaliza el cuerpo para el hash: strip por linea + colapsa blancos. Cambios irrelevantes de whitespace (espacios al final, dobles lineas en blanco) no alteran el hash; cambios de contenido SI. Esto hace el hash robusto sin perder la capacidad de detectar ediciones reales. """ out = [] prev_blank = False for raw in body.splitlines(): line = raw.strip() if line == "": if prev_blank: continue prev_blank = True else: prev_blank = False out.append(line) return "\n".join(out).strip() def _content_hash(body: str) -> str: """sha256 hex del cuerpo NORMALIZADO (nunca del frontmatter).""" return hashlib.sha256(_normalize(body).encode("utf-8")).hexdigest() def _parse_frontmatter(text: str) -> dict: """Parsea el frontmatter ``--- ... ---`` simple (key: value) de un .md.""" if not text.startswith("---"): return {} parts = text.split("---", 2) if len(parts) < 3: return {} fm = {} for line in parts[1].splitlines(): line = line.strip() if not line or ":" not in line: continue key, _, value = line.partition(":") fm[key.strip()] = value.strip() return fm def _render_file(slug: str, frozen_at: str, content_hash: str, body: str) -> str: """Compone el archivo completo: frontmatter frozen + cuerpo.""" return ( "---\n" f"paper_slug: {slug}\n" f"frozen_at: {frozen_at}\n" f"content_hash: {content_hash}\n" "status: frozen\n" "---\n" "\n" f"{body}\n" ) def preregister_hypothesis(paper_dir: str, hypotheses: dict, analysis_plan: dict) -> dict: """Congela la hipotesis y el plan de analisis de un paper (anti-HARKing). Escribe ``/preregistration.md`` con frontmatter ``status: frozen`` y un cuerpo markdown determinista. Una vez congelado es inmutable. Args: paper_dir: ruta del directorio del paper (p.ej. ``"papers/0001-mi-paper"``). El ``paper_slug`` es el basename del directorio. Debe existir. hypotheses: dict de hipotesis, p.ej. ``{"h0": "no hay diferencia ...", "h1": "grupo A > grupo B ..."}``. analysis_plan: dict con el plan, p.ej. ``{"test": "welch_t_test", "effect_size_metric": "cohens_d", "decision_rule": "...", "planned_n": 100, "multiple_correction": "holm"}``. Returns: dict dict-no-throw (NUNCA lanza). Claves segun el caso: - frozen: {"status": "frozen", "path", "content_hash", "frozen_at"} - unchanged: {"status": "unchanged", "path", "content_hash", "frozen_at"} - error: {"status": "error", "path", "note", ...} """ expected_path = os.path.join(paper_dir, "preregistration.md") try: # 1) El directorio del paper debe existir; no se crea aqui. if not isinstance(paper_dir, str) or not os.path.isdir(paper_dir): return { "status": "error", "path": expected_path, "note": f"paper_dir no existe: {paper_dir}", } if not isinstance(hypotheses, dict) or not isinstance(analysis_plan, dict): return { "status": "error", "path": expected_path, "note": "hypotheses y analysis_plan deben ser dict", } slug = os.path.basename(os.path.normpath(paper_dir)) # 2) + 3) Cuerpo determinista y su hash (solo del cuerpo, no del frontmatter). body = _build_body(hypotheses, analysis_plan) new_hash = _content_hash(body) # 5) Logica de escritura. if os.path.exists(expected_path): existing = "" try: with open(expected_path, "r", encoding="utf-8") as fh: existing = fh.read() except OSError as exc: return { "status": "error", "path": expected_path, "note": f"no se pudo leer el pre-registro existente: {exc}", } fm = _parse_frontmatter(existing) old_status = fm.get("status", "") old_hash = fm.get("content_hash", "") old_frozen_at = fm.get("frozen_at", "") if old_status == "frozen": if old_hash == new_hash: # Idempotente: mismo contenido ya congelado. No se reescribe. return { "status": "unchanged", "path": expected_path, "content_hash": new_hash, "frozen_at": old_frozen_at, } # Inmutabilidad: ya congelado con OTRO hash -> se rechaza (anti-HARKing). return { "status": "error", "path": expected_path, "content_hash": new_hash, "note": ( "pre-registro inmutable: ya esta congelado (frozen) con un " "hash distinto; un pre-registro no se puede editar tras " "congelarse" ), } # status != "frozen" (p.ej. draft) -> se congela ahora. # Archivo nuevo o draft existente: congelar con timestamp actual. frozen_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") file_text = _render_file(slug, frozen_at, new_hash, body) try: with open(expected_path, "w", encoding="utf-8") as fh: fh.write(file_text) except OSError as exc: return { "status": "error", "path": expected_path, "note": f"no se pudo escribir el pre-registro: {exc}", } return { "status": "frozen", "path": expected_path, "content_hash": new_hash, "frozen_at": frozen_at, } except Exception as exc: # noqa: BLE001 - dict-no-throw: nunca propagar. return { "status": "error", "path": expected_path, "note": f"error inesperado: {exc}", }