#!/usr/bin/env python3 """ render_spec.py — deterministic YAML -> Markdown renderer for the Step 3 binding analysis spec. The YAML spec is the source of truth. This script ONLY formats what the spec already contains: no LLM, no catalog lookups, no network. Catalog/instrument certification is verify_spec.py's job and runs BEFORE this (journal Step 13). render_spec.py assumes a clean spec and fails loudly on a malformed one. Usage: render_spec.py .yaml [-o .md] Reference output: 12_layout_exploration/output/format_examples/format_1_REVISED_analysis_spec.md """ import argparse import sys import yaml def cell(text): """Make a value safe for a single Markdown table cell. Collapses all whitespace (including newlines from YAML block scalars) to single spaces and escapes pipes so they don't split the cell. """ if text is None: return "—" s = str(text) s = s.replace("\r\n", "\n").replace("\r", "\n") s = " ".join(s.split()) # collapse all whitespace runs (incl. newlines) to single spaces s = s.replace("|", "\\|") # escape pipes so they don't split the cell return s or "—" DIM_LABEL = { "sector": "Sector", "pillar": "Pillar (2D)", "subpillar": "Subpillar (2D)", "cross_cutting": "Cross-cutting lens", } COLS = ( "| Indicator | Definition (catalog) | Measurable | Reasons (proves / blocks) " "| Variables in the dataset | Indicator name in the analysis |" ) SEP = "|---|---|---|---|---|---|" def die(msg): sys.stderr.write(f"render_spec.py: {msg}\n") sys.exit(1) def require(spec, key): if key not in spec: die(f"required key '{key}' missing from spec") return spec[key] def fmt_vars(v): return ", ".join(f"`{x}`" for x in v.get("variables", [])) or "—" def fmt_result_id(result_id, v): ids = v.get("result_ids") if ids: return ", ".join(f"`{i}`" for i in ids) return f"`{result_id}`" def dim_key(dim): # "sector::WASH" -> ("sector", "WASH") base = dim.split("::", 1)[0] return base, dim def render(spec): out = [] src = spec.get("_source_file", ".yaml") out.append(f"") out.append("") out.append(f"# Analysis Spec — {require(spec, 'dataset')}") out.append("") out.append(f"**Unit of analysis:** {spec.get('unit_of_analysis', '—')} · " f"**Catalog version (Step 2):** {spec.get('step2_catalog_version', '—')} · " f"**n:** {spec.get('n_total', '—')}") route = spec.get("step1_framework_route", {}) if route: out.append(f"**Route:** Sector `{route.get('sector','—')}` · " f"Pillar `{route.get('pillar_2d','—')}` · " f"Subpillar `{route.get('subpillar_2d','—')}`" + (f" · Cross-cutting `{route['cross_cutting']}`" if route.get("cross_cutting") else "")) out.append("**Status:** ☐ DRAFT → ☐ REVIEWED → ☐ APPROVED → ☐ ANALYSIS RUN") out.append("") out.append("---") out.append("") # ---- §1 coverage map, one table per dimension present on the indicators ---- # Indicators are grouped by their `dimension` (sector::X or cross_cutting::X). # The pillar/subpillar route is recorded in the header, NOT as a table: in this # catalog every indicator shares one pillar/subpillar anchor, so a pillar table # would only duplicate the sector table. There is therefore no inheritance to # cross-reference — each indicator simply appears once under its own dimension. out.append("## 1. Coverage map — by framework dimension (Step 1)") out.append("") out.append("*One table per dimension that carries indicators (sector, plus any " "cross-cutting lens). The pillar/subpillar route is in the header above. " "Each indicator appears once.*") out.append("") indicators = require(spec, "indicators") # Group by full dimension string; sort within each group MEASURABLE→PROXY→NOT_MEASURABLE then id asc. # This makes the rendered MD canonical regardless of YAML insertion order (D13). MEASURABLE_RANK = {"MEASURABLE": 0, "PROXY": 1, "NOT_MEASURABLE": 2} # Human-readable verdict labels (internal YAML values → display) VERDICT_LABEL = {"MEASURABLE": "Measurable", "PROXY": "Proxy", "NOT_MEASURABLE": "Not measurable"} order = [] by_dim = {} for rid, v in indicators.items(): dim = v.get("dimension", "sector::?") _, full = dim_key(dim) by_dim.setdefault(full, []).append((rid, v)) if full not in order: order.append(full) # Sort each dimension's indicators: MEASURABLE first, then PROXY, then NOT_MEASURABLE, then id asc for full in order: by_dim[full].sort( key=lambda rv: (MEASURABLE_RANK.get(rv[1].get("measurable", ""), 9), rv[0]) ) for section, full in enumerate(order, start=1): base, _ = dim_key(full) label = DIM_LABEL.get(base, base) out.append(f"### 1.{section} {label} — `{full.split('::', 1)[1]}`") out.append("") out.append(COLS) out.append(SEP) for rid, v in by_dim[full]: verdict_raw = v.get('measurable', '') verdict_display = VERDICT_LABEL.get(verdict_raw, cell(verdict_raw)) # "Indicator name in the analysis" is empty for Not measurable rows result_cell = "" if verdict_raw == "NOT_MEASURABLE" else fmt_result_id(rid, v) out.append( "| " + cell(v.get('label', rid)) + " | " + cell(v.get('definition', '—')) + " | " + verdict_display + " | " + cell(v.get('reasons', '—')) + " | " + fmt_vars(v) + " | " + result_cell + " |" ) out.append("") # ---- §1c uncovered modules ---- uncovered = spec.get("uncovered_modules") if uncovered: section_count = len(order) + 1 out.append(f"### 1.{section_count} Survey modules with no catalog indicator") out.append("") out.append("| Module | Variables | Note |") out.append("|---|---|---|") for entry in uncovered: mod = cell(entry.get("module", "—")) vars_str = ", ".join(f"`{v}`" for v in entry.get("variables", [])) or "—" note = cell(entry.get("note", "no catalog indicator")) out.append(f"| {mod} | {vars_str} | {note} |") out.append("") # ---- §1b disaggregation ---- dis = spec.get("disaggregation") if dis: out.append("---") out.append("") out.append("## 1b. Disaggregation required") out.append("") out.append("Report **every indicator row above separately** for:") out.append("") groups = dis.get("groups", {}) srcs = ", ".join(f"`{s}`" for s in dis.get("source_variables", [])) # Only show n column if at least one group has a real count has_counts = any(v is not None for v in groups.values()) if has_counts: out.append("| Group | n | Source variable |") out.append("|---|---|---|") for g, n in groups.items(): n_val = cell(str(n)) if n is not None else "—" out.append(f"| {cell(g)} | {n_val} | {srcs} |") else: out.append("| Group | Source variable |") out.append("|---|---|") for g in groups: out.append(f"| {cell(g)} | {srcs} |") out.append("") trig = dis.get("trigger") if trig: out.append(f"**Trigger:** {cell(trig)}. A single pooled figure per indicator is " f"**not** acceptable — each table must carry every group.") out.append("") # ---- §2 gates ---- gates = spec.get("gates", []) if gates: out.append("---") out.append("") out.append("## 2. Pass criteria — gates *(all must hold before the report ships)*") out.append("") out.append("| Gate | Pass condition | ☐ |") out.append("|---|---|---|") for g in gates: out.append(f"| {cell(g.get('id','—'))} | {cell(g.get('assert','—'))} | ☐ |") out.append("") return "\n".join(out) + "\n" def main(): ap = argparse.ArgumentParser() ap.add_argument("spec") ap.add_argument("-o", "--out") a = ap.parse_args() with open(a.spec) as f: spec = yaml.safe_load(f) if not isinstance(spec, dict): die("spec did not parse to a mapping") spec["_source_file"] = a.spec.split("/")[-1] md = render(spec) if a.out: with open(a.out, "w") as f: f.write(md) print(f"wrote {a.out}") else: sys.stdout.write(md) if __name__ == "__main__": main()