4f1530797e
Subsistema de papers reproducibles (grupo de capacidad `papers`). Añade las funciones estadísticas que un paper honesto necesita y la función que congela la hipótesis antes de mirar los datos (anti-HARKing). Nuevas funciones (puras salvo la última): - effect_size_cohens_d: Cohen's d + Hedges' g (corrección de sesgo para N pequeño) + interpretación cualitativa (negligible/small/medium/large por los umbrales de Cohen). Dict-no-throw ante varianza cero / N insuficiente. - confidence_interval_mean: intervalo de confianza de una media (t de Student) o de la diferencia de medias con Welch (df de Welch–Satterthwaite, sin asumir varianzas iguales). Dict-no-throw; el IC colapsa al punto cuando la varianza es cero. - preregister_hypothesis (impura): congela hipótesis + plan de análisis en papers/<slug>/preregistration.md con frozen_at (UTC) y content_hash (sha256 del cuerpo normalizado, no del frontmatter). Inmutabilidad: una vez frozen, un contenido distinto se RECHAZA sin sobrescribir (mata el HARKing); idempotente si el contenido es idéntico. Siempre dict-no-throw. Extensión: - fdr_correction 1.0.0 -> 1.1.0: añade method="holm" (Holm-Bonferroni step-down, controla FWER, más potente que Bonferroni simple). Reúsa la maquinaria de alineación 1:1 con None/inválidos; no rompe los métodos bh/bonferroni. Reutiliza del registry: fdr_correction (BH + Bonferroni ya existían) como base para Holm. pearson y spearman_corr ya cubrían correlación. Tests: 36 pytest verdes (cohen/hedges 8, confidence/welch 8, fdr/holm/bonferroni 12, preregister 4 + extras), golden contra valores conocidos y validados con scipy. Golden manual del preregistro: congela, idempotente, rechaza edición (bytes en disco idénticos al congelado). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
7.7 KiB
Python
203 lines
7.7 KiB
Python
"""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 ``<paper_dir>/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 ``<paper_dir>/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}",
|
|
}
|