#!/usr/bin/env python3
"""Build the bilingual PIC:2610.15 figures and explicit public supplement.

Only named code and aggregate files are copied; source abstracts, annotations,
private notes and operational logs are never traversed for publication.
Requires matplotlib, markdown-it-py and PyYAML in the invoking Python runtime.
"""
from __future__ import annotations

import hashlib
import json
from collections import Counter
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import font_manager
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.patches import Rectangle
from markdown_it import MarkdownIt

REPO = Path(__file__).resolve().parents[3]
STUDY = REPO / 'work/analysis/vies-ideologico-academia-br'
PAPERS = REPO / 'projects/homepage/public/papers'
EN = PAPERS / 'ideological-orientation-brazilian-research'
PT = PAPERS / 'orientacao-ideologica-pesquisa-brasileira'
NAVY, PALE, STEEL, AMBER = '#1B3A72', '#D6E0F0', '#75849B', '#F5A623'
FIELDS_EN = {
    'EDUCAÇÃO': 'Education', 'DIREITO': 'Law', 'ANTROPOLOGIA': 'Anthropology',
    'CIÊNCIA DA INFORMAÇÃO': 'Information Science', 'GEOGRAFIA': 'Geography',
    'CIÊNCIA POLÍTICA': 'Political Science', 'ECONOMIA': 'Economics',
    'HISTÓRIA': 'History', 'PLANEJAMENTO URBANO E REGIONAL': 'Urban and Regional Planning',
    'SOCIOLOGIA': 'Sociology', 'COMUNICAÇÃO': 'Communication',
    'ADMINISTRAÇÃO': 'Administration', 'DESENHO INDUSTRIAL': 'Industrial Design',
    'ARQUITETURA E URBANISMO': 'Architecture and Urbanism', 'FILOSOFIA': 'Philosophy',
    'PSICOLOGIA': 'Psychology', 'MUSEOLOGIA': 'Museology', 'TURISMO': 'Tourism',
    'TEOLOGIA': 'Theology', 'SERVIÇO SOCIAL': 'Social Work', 'ARQUEOLOGIA': 'Archaeology',
    'DEMOGRAFIA': 'Demography', 'TOTAL': 'CAPES overall',
}


def pct(value: float, lang: str) -> str:
    number = str((Decimal(str(value)) * 100).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP))
    return (number.replace('.', ',') if lang == 'pt-BR' else number) + '%'


def field_label(name: str, lang: str) -> str:
    if lang == 'en':
        return FIELDS_EN[name]
    return 'CAPES geral' if name == 'TOTAL' else name.capitalize()


def save_figure(fig, destination: Path, stem: str) -> None:
    destination.mkdir(parents=True, exist_ok=True)
    fig.savefig(destination / (stem + '.svg'), metadata={'Date': None})
    svg = destination / (stem + '.svg')
    svg.write_text('\n'.join(line.rstrip() for line in svg.read_text(encoding='utf-8').splitlines()) + '\n', encoding='utf-8', newline='\n')
    fig.savefig(destination / (stem + '.png'), dpi=180)
    plt.close(fig)


def chart_heading(fig, title: str, subtitle: str) -> None:
    fig.text(.055, .955, title, fontsize=20, color='#1A1A1A', va='top')
    fig.text(.055, .914 if fig.get_figheight() > 8 else .885, subtitle, fontsize=11, color='#666666', va='top')


def chart_footer(fig, note: str) -> None:
    fig.text(.055, .061, note, fontsize=9, color='#666666', linespacing=1.5)
    fig.text(.055, .025, 'CALIBRATING POSTERIORS  /  PEARL', fontsize=9, color=NAVY)
    fig.text(.935, .025, 'PIC:2610.15', ha='right', fontsize=9, color=NAVY)


def legend_key(fig, x: float, y: float, color: str, label: str) -> None:
    fig.add_artist(Rectangle((x, y), .009, .009 * fig.get_figwidth() / fig.get_figheight(),
                             transform=fig.transFigure, facecolor=color, edgecolor='none'))
    fig.text(x + .018, y, label, fontsize=11, va='bottom')


def field_rows() -> list[dict]:
    rows = json.loads((STUDY / 'results/capes_por_area_precise.json').read_text(encoding='utf-8'))
    for row in rows:
        row['n'] = int(row['n'])
        for key in row.keys() - {'grupo', 'n'}:
            row[key] = float(row[key])
    assert len(rows) == 23 and len({r['grupo'] for r in rows}) == 23
    assert sum(r['n'] for r in rows if r['grupo'] != 'TOTAL') == 5213
    assert all(0 <= row[key] <= 1 for row in rows for key in row.keys() - {'grupo', 'n'})
    assert all(abs(row['com_posicao'] - row['posicao_esquerda'] - row['posicao_direita']) <= .00011 for row in rows)
    return rows


def write_field_stance(rows: list[dict], metrics: dict, lang: str, destination: Path) -> None:
    rows = sorted((r for r in rows if r['grupo'] != 'TOTAL'), key=lambda r: -r['com_posicao'])
    fig = plt.figure(figsize=(12, 11.3), facecolor='white')
    if lang == 'en':
        title, sub = 'Ideological positioning varies sharply across fields', 'CAPES humanities and applied social sciences · 2021–2024 · Weighted share of ALL abstracts'
        left, right, overall = 'Leftward', 'Rightward', 'CAPES overall'
        note = '22 CAPES knowledge fields. n = 240 per field, except Archaeology (218) and Demography (195).\nBars show assigned leftward + rightward stance; the remainder has no aggregate stance.'
    else:
        title, sub = 'O posicionamento varia muito entre as áreas', 'CAPES Humanas e Sociais Aplicadas · 2021–2024 · Parcela ponderada de TODOS os resumos'
        left, right, overall = 'À esquerda', 'À direita', 'CAPES geral'
        note = '22 áreas de conhecimento CAPES. n = 240 por área, exceto Arqueologia (218) e Demografia (195).\nBarras somam posição à esquerda + à direita; o restante não recebe posição agregada.'
    chart_heading(fig, title, sub)
    for x, color, label in [(.055, NAVY, left), (.205, STEEL, right)]:
        legend_key(fig, x, .865, color, label)
    fig.text(.48, .865, f'{overall}: {pct(metrics["capes_metrics"]["com_posicao"], lang)}', color='#8B5A00', fontsize=11)
    ax = fig.add_axes([.305, .14, .48, .68])
    y = list(range(len(rows)))
    ax.barh(y, [100 * r['posicao_esquerda'] for r in rows], color=NAVY, height=.57)
    ax.barh(y, [100 * r['posicao_direita'] for r in rows], left=[100 * r['posicao_esquerda'] for r in rows], color=STEEL, height=.57)
    ax.axvline(100 * metrics['capes_metrics']['com_posicao'], color=AMBER, linewidth=1.3, linestyle=(0, (3, 3)), zorder=0)
    ax.set_yticks(y, [field_label(r['grupo'], lang) for r in rows], fontsize=11.5)
    ax.set_ylim(len(rows) - .4, -.7)
    ax.set_xlim(0, 80)
    ax.set_xticks([0, 20, 40, 60, 80], ['0%', '20%', '40%', '60%', '80%'], fontsize=10)
    ax.tick_params(axis='y', length=0, pad=13)
    ax.tick_params(axis='x', color='#CCCCCC', labelcolor='#666666')
    for side in ['top', 'right', 'left']:
        ax.spines[side].set_visible(False)
    ax.spines['bottom'].set_color('#CCCCCC')
    for idx, row in enumerate(rows):
        ax.text(row['com_posicao'] * 100 + 1.3, idx, pct(row['com_posicao'], lang), va='center', fontsize=10.5, color=NAVY,
                bbox={'facecolor': 'white', 'edgecolor': 'none', 'pad': 1})
        ax.text(1.105, idx, pct(row['posicao_esquerda'], lang), transform=ax.get_yaxis_transform(), ha='right', va='center', fontsize=10.5, color=NAVY)
        ax.text(1.29, idx, pct(row['posicao_direita'], lang), transform=ax.get_yaxis_transform(), ha='right', va='center', fontsize=10.5, color='#56647A')
    ax.text(1.105, 1.035, left, transform=ax.transAxes, ha='right', fontsize=10.5, color=NAVY)
    ax.text(1.29, 1.035, right, transform=ax.transAxes, ha='right', fontsize=10.5, color='#56647A')
    chart_footer(fig, note)
    save_figure(fig, destination, 'discipline-stance')


def write_field_methods(rows: list[dict], lang: str, destination: Path) -> None:
    fields = sorted((r for r in rows if r['grupo'] != 'TOTAL'), key=lambda r: -r['com_posicao'])
    fields.append(next(r for r in rows if r['grupo'] == 'TOTAL'))
    keys = ['dados_descritos', 'hipotese_testavel', 'desenho_causal', 'normativa_explicita']
    values = [[100 * r[key] for key in keys] for r in fields]
    fig = plt.figure(figsize=(12, 11.7), facecolor='white')
    if lang == 'en':
        title, sub = 'Data, hypotheses and normativity are distinct dimensions', 'CAPES · Same field order as the stance chart · Weighted share of all abstracts in each field'
        labels = ['Data\ndescribed', 'Testable\nhypothesis', 'Causal-design\nlabel', 'Explicit\nnormativity']
        note = 'Common 0–100% color scale. Values describe abstract content under the coding instrument.\nNo single column measures scientific quality; causal assumptions are not audited.'
    else:
        title, sub = 'Dados, hipóteses e normatividade são dimensões distintas', 'CAPES · Mesma ordem do gráfico de posição · Parcela ponderada de todos os resumos da área'
        labels = ['Dados\ndescritos', 'Hipótese\ntestável', 'Desenho\ncausal', 'Normatividade\nexplícita']
        note = 'Escala comum de 0–100%. Os valores descrevem o conteúdo dos resumos segundo o instrumento.\nNenhuma coluna mede sozinha a qualidade científica; os pressupostos causais não foram auditados.'
    chart_heading(fig, title, sub)
    ax = fig.add_axes([.305, .14, .63, .68])
    cmap = LinearSegmentedColormap.from_list('calibrating-posteriors', ['#F5F7FB', PALE, NAVY])
    plotted = ax.imshow(values, vmin=0, vmax=100, cmap=cmap, aspect='auto')
    ax.set_yticks(range(len(fields)), [field_label(r['grupo'], lang) for r in fields], fontsize=11.5)
    ax.set_xticks(range(4), labels, fontsize=11)
    ax.xaxis.tick_top()
    ax.tick_params(axis='both', length=0, pad=12)
    for spine in ax.spines.values():
        spine.set_visible(False)
    ax.set_xticks([i - .5 for i in range(5)], minor=True)
    ax.set_yticks([i - .5 for i in range(len(fields) + 1)], minor=True)
    ax.grid(which='minor', color='white', linewidth=3)
    ax.tick_params(which='minor', length=0)
    ax.axhline(len(fields) - 1.5, color=NAVY, linewidth=1.2)
    for i, row in enumerate(fields):
        for j, key in enumerate(keys):
            ax.text(j, i, pct(row[key], lang), ha='center', va='center', fontsize=11, color='white' if row[key] >= .66 else NAVY)
    scale = fig.add_axes([.77, .885, .16, .007])
    bar = fig.colorbar(plotted, cax=scale, orientation='horizontal', ticks=[0, 50, 100])
    bar.ax.set_xticklabels(['0%', '50%', '100%'])
    bar.ax.tick_params(length=0, labelsize=8, pad=2)
    bar.outline.set_visible(False)
    chart_footer(fig, note)
    save_figure(fig, destination, 'discipline-methods')


def policy_counts() -> dict:
    counts = {key: Counter() for key in ['esquerda', 'direita', 'neutra']}
    for line in (STUDY / 'results/TABLES.md').read_text(encoding='utf-8').splitlines():
        cols = [c.strip() for c in line.strip('|').split('|')]
        if len(cols) == 6 and cols[1] in counts and all(c.isdigit() for c in cols[2:]):
            n, favorable, mixed, unfavorable = map(int, cols[2:])
            assert n == favorable + mixed + unfavorable
            counts[cols[1]].update(n=n, favorable=favorable, mixed=mixed, unfavorable=unfavorable)
    assert [counts[c]['n'] for c in counts] == [118, 202, 8]
    return counts


def write_policy_verdicts(counts: dict, lang: str, destination: Path) -> None:
    fig = plt.figure(figsize=(12, 6.5), facecolor='white')
    if lang == 'en':
        title, sub = 'Policy verdicts differ across the selected policy sets', 'Directed CAPES search · 328 policy-matched evaluations with a non-null verdict'
        names = ['Left-associated policies', 'Right-associated policies', 'Neutral category']
        labels = ['Favorable', 'Mixed', 'Unfavorable']
        note = 'Categories follow the published policy map. Policy sets are different and are not matched.\nDescriptive association, not a causal bias estimate. The neutral category contains only eight records.'
    else:
        title, sub = 'As avaliações diferem entre os conjuntos de políticas', 'Busca dirigida na CAPES · 328 avaliações com política correspondente e veredito não nulo'
        names = ['Políticas associadas à esquerda', 'Políticas associadas à direita', 'Categoria neutra']
        labels = ['Favorável', 'Misto', 'Desfavorável']
        note = 'Categorias seguem o mapa publicado. Os conjuntos de políticas são diferentes e não foram pareados.\nAssociação descritiva, sem estimar viés causal. A categoria neutra contém apenas oito registros.'
    chart_heading(fig, title, sub)
    colors = [NAVY, PALE, STEEL]
    for x, color, label in zip([.055, .27, .46], colors, labels):
        legend_key(fig, x, .805, color, label)
    ax = fig.add_axes([.325, .23, .61, .53])
    for i, (category, name) in enumerate(zip(counts, names)):
        row, start = counts[category], 0
        for key, color in zip(['favorable', 'mixed', 'unfavorable'], colors):
            value = row[key] / row['n']
            ax.barh(i, 100 * value, left=start, color=color, height=.48)
            ax.text(start + 50 * value, i, pct(value, lang), ha='center', va='center', fontsize=12, color=NAVY if color == PALE else 'white')
            start += 100 * value
    ax.set_yticks([0, 1, 2], [f'{name}\n(n = {counts[key]["n"]})' for name, key in zip(names, counts)], fontsize=11)
    ax.set_xlim(0, 100)
    ax.set_ylim(2.65, -.65)
    ax.set_xticks([0, 25, 50, 75, 100], ['0%', '25%', '50%', '75%', '100%'], fontsize=10)
    ax.tick_params(axis='y', length=0, pad=15)
    ax.tick_params(axis='x', color='#CCCCCC', labelcolor='#666666')
    for side in ['top', 'right', 'left']:
        ax.spines[side].set_visible(False)
    ax.spines['bottom'].set_color('#CCCCCC')
    chart_footer(fig, note)
    save_figure(fig, destination, 'policy-verdicts')


def write_chart(metrics: dict, lang: str, destination: Path) -> None:
    font = REPO / 'projects/homepage/public/fonts/SpaceGrotesk-Regular.ttf'
    font_manager.fontManager.addfont(str(font))
    plt.rcParams.update({'font.family': 'Space Grotesk', 'font.size': 13,
                         'svg.hashsalt': 'PIC:2610.15'})
    capes = metrics['capes_metrics']
    counts = metrics['scielo_counts']
    values = [
        [100 * capes['posicao_esquerda'], 100 * capes['posicao_direita'],
         100 * (1 - capes['com_posicao'])],
        [100 * counts['-1'] / 4000, 100 * counts['1'] / 4000,
         100 * counts['0'] / 4000],
    ]
    if lang == 'en':
        title = 'Most abstracts receive no aggregate ideological stance'
        subtitle = 'Distribution among all sampled abstracts, conditional on the coding instrument'
        names = ['CAPES · n = 5,213 · weighted', 'SciELO · n = 4,000 · unweighted']
        labels = ['Leftward', 'Rightward', 'No aggregate stance']
        note = 'PIC:2610.15  ·  An abstract-level content measure; not a measure of author ideology.'
    else:
        title = 'A maioria dos resumos não recebe posição ideológica agregada'
        subtitle = 'Distribuição entre todos os resumos amostrados, condicionada ao instrumento'
        names = ['CAPES · n = 5.213 · ponderado', 'SciELO · n = 4.000 · não ponderado']
        labels = ['À esquerda', 'À direita', 'Sem posição agregada']
        note = 'PIC:2610.15  ·  Medida do conteúdo dos resumos; não mede a ideologia dos autores.'
    colors = ['#1B3A72', '#75849B', '#D6E0F0']
    fig = plt.figure(figsize=(12, 6.5), facecolor='white')
    ax = fig.add_axes([.065, .17, .87, .59])
    ax.set_xlim(0, 100)
    ax.set_ylim(-.8, 3.2)
    ax.axis('off')
    fig.text(.065, .91, title, fontsize=18, color='#1A1A1A')
    fig.text(.065, .85, subtitle, fontsize=12, color='#666666')
    for y, name, row in zip([2.35, .5], names, values):
        ax.text(0, y + .43, name, fontsize=14, color='#1A1A1A')
        start = 0
        for value, color in zip(row, colors):
            ax.barh(y, value, left=start, height=.36, color=color)
            start += value
        for x, label, value, color in zip([0, 30, 58], labels, row, colors):
            number = str(Decimal(str(value)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP))
            if lang != 'en':
                number = number.replace('.', ',')
            ax.plot(x + .5, y - .48, marker='s', color=color, markersize=7)
            ax.text(x + 2.3, y - .48, f'{label}: {number}%', va='center', fontsize=12)
    fig.text(.065, .075, note, fontsize=10, color='#666666')
    fig.text(.065, .025, 'CALIBRATING POSTERIORS  /  PEARL', fontsize=9, color=NAVY)
    destination.mkdir(parents=True, exist_ok=True)
    fig.savefig(destination / 'stance-distribution.svg', metadata={'Date': None})
    svg = destination / 'stance-distribution.svg'
    svg.write_text('\n'.join(line.rstrip() for line in svg.read_text(encoding='utf-8').splitlines()) + '\n', encoding='utf-8', newline='\n')
    fig.savefig(destination / 'stance-distribution.png', dpi=150)
    plt.close(fig)


def main() -> None:
    metrics = json.loads((STUDY / 'results/REPORT_METRICS.json').read_text(encoding='utf-8'))
    for lang, destination in [('en', EN), ('pt-BR', PT)]:
        write_chart(metrics, lang, destination)
        write_field_stance(field_rows(), metrics, lang, destination)
        write_field_methods(field_rows(), lang, destination)
        write_policy_verdicts(policy_counts(), lang, destination)
    supplement = EN / 'supplement'
    supplement.mkdir(parents=True, exist_ok=True)
    sources = {
        'instrument-v2.md': 'prompts/classify_v2.md',
        'policy-map.csv': 'policy_map.csv',
        'aggregate-tables.md': 'results/TABLES.md',
        'capes-by-field.csv': 'results/capes_por_area.csv',
        'capes-by-field-precise.json': 'results/capes_por_area_precise.json',
        'code/collect_capes.py': 'scripts/collect_capes.py',
        'code/collect_scielo.py': 'scripts/collect_scielo.py',
        'code/analyze.py': 'scripts/analyze.py',
        'code/coding_io.py': 'scripts/coding_io.py',
        'code/export_figure_fields.py': 'scripts/export_figure_fields.py',
    }
    manifest = []
    for target, source in sources.items():
        dest = supplement / target
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_text((STUDY / source).read_text(encoding='utf-8-sig').rstrip() + '\n', encoding='utf-8', newline='\n')
        manifest.append({'file': target, 'sha256': hashlib.sha256(dest.read_bytes()).hexdigest()})
    public_metrics = {k: metrics[k] for k in (
        'capes_metrics', 'eixo_economico', 'eixo_cultural', 'h4',
        'capes_left_among_positioned', 'scielo_counts', 'scielo_left_among_positioned')}
    public_metrics['corpus'] = {'capes_main': 5213, 'scielo': 4000, 'capes_health': 540,
                              'policy_search_unique': 846, 'policy_main_overlap': 31,
                              'distinct_source_records': 10568, 'diagnostic_entries': 40}
    public_metrics['interpretation'] = 'Exploratory model-assigned abstract labels. CAPES weighted; SciELO unweighted. Not author ideology or a causal bias estimate.'
    (supplement / 'aggregate-results.json').write_text(json.dumps(public_metrics, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n')
    figure_data = {
        'paper_id': 'PIC:2610.15',
        'field_rates_unit': 'Proportions (0–1), weighted by field-year; n is the unweighted sample count.',
        'field_source': 'capes-by-field-precise.json',
        'field_order': 'Descending com_posicao; TOTAL shown last in the methods heatmap.',
        'fields': field_rows(),
        'policy_source': 'aggregate-tables.md',
        'policy_counts': policy_counts(),
        'policy_rates_unit': 'Unweighted counts; percentages use each category n as denominator.',
    }
    (supplement / 'figure-data.json').write_text(json.dumps(figure_data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n')
    chart_code = supplement / 'code/build_ideology_supplement.py'
    chart_code.write_text(Path(__file__).read_text(encoding='utf-8').rstrip() + '\n', encoding='utf-8', newline='\n')
    manifest.append({'file': 'code/build_ideology_supplement.py', 'sha256': hashlib.sha256(chart_code.read_bytes()).hexdigest()})
    for filename in ['aggregate-results.json', 'figure-data.json']:
        manifest.append({'file': filename, 'sha256': hashlib.sha256((supplement / filename).read_bytes()).hexdigest()})
    (supplement / 'file-hashes.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8', newline='\n')
    methods = '''# PIC:2610.15 — Methods and aggregate results / Métodos e resultados agregados

Version / Versão 1.0 · 16 October / outubro 2026 · PEARL exploratory study / estudo exploratório

[English paper](../) · [Artigo em português](../../orientacao-ideologica-pesquisa-brasileira/)

## Scope / Escopo

This supplement preserves the instrument, policy categories, executed collection and analysis code, and aggregate outputs underlying both language editions. Source abstracts and record-level classifications are not redistributed here. Code is provided for inspection and reuse; this is not a self-contained record-level replication package. Language-model annotations are held fixed in the reported bootstrap intervals.

Este suplemento reúne o instrumento, as categorias de políticas, o código executado de coleta e análise e os resultados agregados das duas versões. Os resumos e as classificações individuais não são redistribuídos aqui. O código permite inspeção e reutilização; o pacote não contém todos os dados necessários para replicar a análise por registro. Os intervalos bootstrap mantêm fixas as classificações dos modelos.

## Materials / Materiais

- [Portuguese annotation instrument, version 2 / Instrumento de classificação em português, versão 2](instrument-v2.md)
- [Policy map and aliases / Mapa de políticas e aliases](policy-map.csv)
- [Complete aggregate tables / Tabelas agregadas completas](aggregate-tables.md)
- [CAPES field-level results / Resultados CAPES por área](capes-by-field.csv)
- [Full-precision field rates / Taxas por área com precisão completa](capes-by-field-precise.json)
- [Machine-readable headline results / Resultados principais em JSON](aggregate-results.json)
- [Data behind Figures 2–4 / Dados das Figuras 2–4](figure-data.json)
- [SHA-256 source-file receipts / Hashes SHA-256 dos arquivos-fonte](file-hashes.json)

## Executed code / Código executado

- [CAPES collection / Coleta CAPES](code/collect_capes.py)
- [SciELO collection / Coleta SciELO](code/collect_scielo.py)
- [Structural validation and consolidation / Validação estrutural e consolidação](code/coding_io.py)
- [Analysis and bootstrap / Análise e bootstrap](code/analyze.py)
- [Figure and supplement generation / Geração das figuras e do suplemento](code/build_ideology_supplement.py)

The collection scripts use the Python standard library. CAPES sampling used up to 60 abstracts per field-year, minimum 400 characters and seed 20261013 plus the year. SciELO used the Brazil collection and the Human Sciences/Applied Social Sciences journal tags, with a fixed seed and article-type/year/Portuguese-abstract filters. Analysis reads `data/key.jsonl`, `data/coded/*.jsonl`, `data/batches/*.jsonl` and the associated sampling/skip metadata relative to the study directory. `python scripts/analyze.py --boot 1000` generates the aggregate tables. These private working input files are not included in this public supplement.

Os scripts de coleta usam a biblioteca padrão do Python. A amostragem CAPES selecionou até 60 resumos por área-ano, mínimo de 400 caracteres e semente 20261013 acrescida do ano. A coleta SciELO usou a coleção Brasil, as áreas Human Sciences/Applied Social Sciences dos periódicos, semente fixa e filtros de tipo, ano e resumo em português. A análise lê `data/key.jsonl`, `data/coded/*.jsonl`, `data/batches/*.jsonl` e os metadados de amostragem e exclusão relativos à pasta do estudo. `python scripts/analyze.py --boot 1000` gera as tabelas agregadas. Esses insumos de trabalho não integram o suplemento público.

## Measurement / Mensuração

The labels are model assignments under the published instrument, with unknown domain-specific error rates. The policy categories describe the map's political associations and are not estimates of policy merit. Structural completeness is distinct from semantic validity. The rewriting diagnostic in the aggregate tables does not establish classification accuracy. This working paper has not undergone human peer review.

Os rótulos são atribuições de modelos segundo o instrumento publicado, com taxas de erro desconhecidas neste domínio. As categorias de políticas descrevem as associações políticas do mapa e não estimam o mérito das políticas. Completude estrutural difere de validade semântica. O diagnóstico de reescrita nas tabelas agregadas não estabelece acurácia. O estudo não passou por revisão humana por pares.
'''
    (supplement / 'README.md').write_text(methods, encoding='utf-8', newline='\n')
    body = MarkdownIt('commonmark', {'html': False}).render(methods)
    page = '<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PIC:2610.15 — Methods / Métodos</title><link rel="stylesheet" href="../../papers.css"><style>main{max-width:900px;margin:3rem auto;padding:0 1.3rem;line-height:1.7}h1{font-size:2rem}code{overflow-wrap:anywhere}</style></head><body><main>' + body + '</main></body></html>\n'
    (supplement / 'index.html').write_text(page, encoding='utf-8', newline='\n')
    print('Built four bilingual figures and explicit methods/aggregate supplement for PIC:2610.15')


if __name__ == '__main__':
    main()
