#!/usr/bin/env python3
"""Build the offline report from the final, canonical study data."""
from pathlib import Path
import base64, collections, csv, hashlib, html, json, re
from urllib.parse import urlparse

ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / 'data'
SITE = ROOT.parents[1] / 'effort-frontend'
def read(name): return json.loads((DATA/name).read_text())
def esc(v): return html.escape(str(v), quote=True)
def n(v): return f'{int(v):,}'
def pct(num, den): return f'{100*num/den:.1f}%' if den else 'Undefined'
def label(v): return 'Program / funding record' if v else 'No record found'
def link(url, title=None): return f'<a href="{esc(url)}" rel="nofollow">{esc(title or urlparse(url).netloc or url)}</a>'
def number(v): return (esc(n(v)), v)
def cell(v):
    if isinstance(v,tuple): display, raw=v
    else: display,raw=esc(v),v
    return f'<td data-sort="{esc(raw)}">{display}</td>'
def table(tid, headers, rows, *, wide=False, long=False):
    heads=''.join(f'<th scope="col"><button type="button" data-column="{i}">{esc(x)}<span aria-hidden="true"> ↕</span></button></th>' for i,x in enumerate(headers))
    body=''.join('<tr>'+''.join(cell(c) for c in row)+'</tr>' for row in rows)
    paginate = not long and not wide and len(rows) > 14
    markup = f'<div class="table-scroll {"long" if long else ""} {"screen-only-table" if paginate else ""}" tabindex="0" role="region" aria-label="{esc(tid.replace("-"," "))}"><table id="{tid}" aria-label="{esc(tid.replace("-"," "))}" class="sortable {"wide-table" if wide else ""}"><thead><tr>{heads}</tr></thead><tbody>{body}</tbody></table></div>'
    if paginate:
        for start in range(0,len(rows),12):
            chunk=''.join('<tr>'+''.join(cell(c) for c in row)+'</tr>' for row in rows[start:start+12])
            markup+=f'<div class="print-only table-scroll print-table-chunk"><table id="{tid}-print-{start//12+1}" aria-label="{esc(tid.replace("-"," "))} continued" class="sortable"><thead><tr>{heads}</tr></thead><tbody>{chunk}</tbody></table></div>'
    return markup
def collect_sources(item, out=None):
    out=[] if out is None else out
    if isinstance(item,dict):
        title=item.get('source_title') or item.get('label') or item.get('title') or item.get('name')
        for k,v in item.items():
            if isinstance(v,str) and re.match(r'https?://',v) and ('url' in k or k in ('source','link')):
                out.append((v,title or urlparse(v).netloc))
            elif isinstance(v,(dict,list)): collect_sources(v,out)
    elif isinstance(item,list):
        for v in item:
            if isinstance(v,str) and re.match(r'https?://',v): out.append((v,urlparse(v).netloc))
            else: collect_sources(v,out)
    return out

def source_links(obj):
    seen=set(); items=[]
    for url,title in collect_sources(obj):
        if url not in seen:
            seen.add(url); items.append(link(url,title))
    return '<br>'.join(items) or 'Source unresolved'

def matrix_values(m):
    cells=m['cells']
    if isinstance(cells,dict): cells=list(cells.values())
    if cells and isinstance(cells[0],list): cells=sum(cells,[])
    if len(cells)!=4: raise ValueError(f'Expected four cells: {cells}')
    a,b,c,d=map(int,cells)
    assert a+b+c+d==m['total']
    return a,b,c,d

def matrix_table(key,m):
    a,b,c,d=matrix_values(m)
    rows=[['Program / funding record',number(a),number(b),number(a+b)],['No record found',number(c),number(d),number(c+d)]]
    return table(key,['Supervisor / recruiter record','Employee: record','Employee: no record','Total'],rows)

def conditional_table(key,m):
    a,b,c,d=matrix_values(m)
    rows=[]
    def p(num,den): return (pct(num,den),100*num/den if den else '')
    rows += [['Employees of supervisors with a record',p(a,a+b),p(b,a+b),number(a+b)],['Employees of supervisors with no record',p(c,c+d),p(d,c+d),number(c+d)]]
    rows += [['Supervisors of employees with a record',p(a,a+c),p(c,a+c),number(a+c)],['Supervisors of employees with no record',p(b,b+d),p(d,b+d),number(b+d)]]
    return table(key,['Conditional group','With record','No record','Denominator'],rows)

def distribution_figure(tid,title,groups,footnote):
    bars=[]
    for name,yes,no in groups:
        total=yes+no
        widths=[100*yes/total if total else 0,100*no/total if total else 0]
        bars.append(f'<div class="bar-row"><div class="bar-label">{esc(name)} <span>(n = {n(total)})</span></div><div class="bar-track" aria-label="{n(yes)} with a record; {n(no)} with no record">'+(f'<span class="bar-solid" style="width:{widths[0]:.8f}%"></span><span class="bar-open" style="width:{widths[1]:.8f}%"></span>' if total else '<span class="bar-empty">Undefined: zero denominator</span>')+f'</div><div class="bar-values"><span>{n(yes)} with a record · {pct(yes,total)}</span><span>{n(no)} with no record · {pct(no,total)}</span></div></div>')
    return f'<figure id="{tid}" class="chart" data-graphic-key="{tid}" aria-labelledby="{tid}-title"><figcaption id="{tid}-title">{esc(title)}<sup><a id="ref-{footnote}" href="#note-{footnote}">{footnote}</a></sup></figcaption><div class="legend"><span><i class="solid"></i> Program / funding record</span><span><i class="open"></i> No record found</span></div>'+''.join(bars)+'</figure>'

S=read('study_results.json'); E=read('employee_census_final.json'); P=read('hiring_pairs_final.json'); provenance=read('extraction_provenance.json')
C=S['cohort']; M=S['matrices']; N=len(E)
F=read('program_funding_evidence.json')
source_covered=sum(bool(x.get('manager_search_status',{}).get('captured_source_record_count')) for x in E)
funding_rows=[[x['program'],x['date'],('$'+n(x['amount']),x['amount']),x.get('description',''),(link(x['source_url'],'Grant record'),x['source_url'])] for x in sorted(F,key=lambda x:(x['program'],x['date']))]
strict_evidence=''.join('<p>'+esc(x['employee_name'])+' identifies '+esc(x['supervisor_name'])+' as the person who recruited them to '+esc(x['company'])+' in '+esc(x['date'])+'. '+link(x['source_url'],x['source_title'])+'.</p>' for x in P if x.get('confirmed_hiring_decision'))
assert N==C['people'],(N,C['people'])
for x in E:
    assert isinstance(x['baseline_marker'],bool)
    assert isinstance(x['secondary_marker'],bool)
base=sum(x['baseline_marker'] for x in E); expanded=sum(x['secondary_marker'] for x in E)
assert base==C['baseline_positive'],(base,C['baseline_positive'])
assert expanded==C['secondary_positive'],(expanded,C['secondary_positive'])
assert N-expanded==C['no_evidence']
by_person={x['person_id']:x for x in E}
for x in E:
    for pid in x.get('holoscope_person_ids',[]): by_person[pid]=x
by_pair={x['id']:x for x in P}
for m in M.values():
    if isinstance(m,dict) and 'cells' in m:
        matrix_values(m)
        if m.get('pair_ids') is not None and m.get('marker'):
            assert len(m['pair_ids'])==len(set(m['pair_ids']))==m['total']
            calculated=[0,0,0,0]
            for pid in m['pair_ids']:
                pair=by_pair[pid]
                employee=bool(by_person.get(pair['employee_id'],{}).get(m['marker'],False))
                supervisor=bool(by_person.get(pair['supervisor_id'],{}).get(m['marker'],False))
                calculated[(0 if supervisor else 2)+(0 if employee else 1)]+=1
            assert calculated==list(matrix_values(m)),(m.get('title'),calculated,m['cells'])
strict=M['strict_hiring']; workplace=M['workplace_supervision']; proxy=M['supervisor_or_recruitment_proxy']
a,b,c,d=matrix_values(proxy)
secondary_pairs=M.get('source_reported_supervision_sensitivity',proxy).get('pair_ids',[])
secondary_unique=len({by_pair[pid]['employee_id'] for pid in secondary_pairs})
secondary_names=sorted({by_pair[pid]['employee_name'] for pid in secondary_pairs if pid not in set(proxy.get('pair_ids',[]))})
secondary_prose=(' The secondary-source sensitivity adds company reporting about '+', '.join(secondary_names)+f'; it leaves {n(N-secondary_unique)} people without a qualifying relationship in that broader sample.') if secondary_names else ''
fig1=distribution_figure('census-coverage','Recorded program and funding links',[('Holoscope baseline',base,N-base),('After source checks',expanded,N-expanded)],1)
fig2=distribution_figure('employee-conditional','Employee records by supervisor / recruiter', [('Supervisor / recruiter: record',a,b),('Supervisor / recruiter: no record',c,d)],2)
fig3=distribution_figure('supervisor-conditional','Supervisor / recruiter records by employee',[('Employee: record',a,c),('Employee: no record',b,d)],3)

# Every source is embedded in an indexed ledger; each census and pair row links directly.
ledger=collections.OrderedDict()
for x in E+P+F:
    who=x.get('person_name') or x.get('employee_name') or x.get('program') or x.get('person_id') or 'Study source'
    for url,title in collect_sources(x):
        entry=ledger.setdefault(url,{'url':url,'title':str(title),'records':set()})
        entry['records'].add(str(who))
for url in provenance.get('source_urls',{}).values():
    ledger.setdefault(url,{'url':url,'title':'Holoscope source snapshot','records':{'Study provenance'}})
ledger_rows=[]; source_export=[]
for i,(url,x) in enumerate(ledger.items(),1):
    names=sorted(x['records']); source_export.append({'id':i,'url':url,'title':x['title'],'records':names})
    ledger_rows.append([number(i),(link(url,x['title']),x['title']),urlparse(url).netloc,number(len(names)),', '.join(names)])
(ROOT/'data'/'report_source_ledger.json').write_text(json.dumps(source_export,indent=2,ensure_ascii=False)+'\n')
with (DATA/'report_source_ledger.csv').open('w') as f:
    writer=csv.DictWriter(f,fieldnames=['id','url','title','records']); writer.writeheader()
    for x in source_export: writer.writerow({**x,'records':'; '.join(x['records'])})

employee_rows=[]
for x in E:
    roles=[]
    if x.get('mats_participant_recorded'): roles.append('MATS participant')
    if x.get('mats_mentor_recorded'): roles.append('MATS mentor')
    extra=x.get('external_program_sources',[])
    
    for program_row in extra:
        program=program_row.get('program','')
        if program and program != 'MATS':
            roles.append(program + ': ' + program_row.get('role','recorded role').replace('_',' '))
    grant=x.get('grant_sources',[])
    if grant or x.get('baseline_funding_sources'): roles.append('Personal funding record')
    if x.get('named_research_funding_sources'): roles.append('Named institutional research funding; sensitivity only')
    pair_ids=x.get('manager_pair_ids',[])
    manager_text=f'{len(pair_ids)} reviewed link'+('s' if len(pair_ids)!=1 else '') if pair_ids else 'Unresolved'
    search_status=x.get('manager_search_status',{})
    audit_text=('Database records scanned; '+str(search_status.get('captured_source_record_count',0))+' captured source texts. '+('Individual web search completed.' if search_status.get('individual_web_search_completed') else 'Complete individual web search: not recorded.'))
    employee_rows.append([
      x.get('person_name',x.get('name','')),
      '; '.join(x.get('company_names',[])),
      label(x['baseline_marker']),label(x['secondary_marker']),
      '; '.join(roles) or 'No program / personal funding record found',
      manager_text,audit_text,
      (source_links(x), ' '.join(u for u,_ in collect_sources(x))),
      x.get('person_id','')
    ])

pair_rows=[]
for x in P:
    pair_rows.append([x.get('employee_name',x.get('employee','')),x.get('supervisor_name',x.get('supervisor','')),x.get('company',''),x.get('edge_type',''),x.get('evidence_status',''),x.get('date','Unknown'),x.get('summary',''),x.get('temporal_status','Unresolved'),('Yes' if x.get('confirmed_hiring_decision') else 'Unresolved'),('Yes' if x.get('include_supervisor_proxy') or x.get('include_recruitment_proxy') else 'Excluded'),(source_links(x),x.get('source_url',''))])

# Small company table is useful in print; the employee ledger retains every employer.
company_people=collections.defaultdict(list)
for x in E:
    for company in set(x.get('company_names',[])): company_people[company].append(x)
role_rows=[]
for key,title in [('secondary_program_people','Any checked program role'),('program_participants','MATS participants'),('mats_mentors','MATS listed mentors'),('program_mentors','Listed mentors across checked programs'),('bluedot_course_relationships','BlueDot course relationships'),('bluedot_course_designers','BlueDot course designers'),('personal_grantees','Personal funding records'),('named_institutional_researchers','Named institutional research funding')]:
    if key in C: role_rows.append([title,number(C[key]),('<a href="#employee-ledger">Employee evidence</a>','Employee evidence')])

company_rows=[]
for company,people in sorted(company_people.items(),key=lambda kv:(-len(kv[1]),kv[0]))[:12]:
    count=sum(x['secondary_marker'] for x in people)
    company_rows.append([company,number(len(people)),number(count),number(len(people)-count),(pct(count,len(people)),100*count/len(people)),('<a href="#employee-ledger">Employee evidence</a>','Employee evidence')])

coverage_rows=[]
def flatten(obj,prefix=''):
    rows=[]
    for k,v in obj.items():
        name=f'{prefix}: {k.replace("_"," ")}' if prefix else k.replace('_',' ')
        if isinstance(v,dict): rows+=flatten(v,name)
        elif isinstance(v,list): rows.append([name,'; '.join(str(z) for z in v)])
        elif isinstance(v,float): rows.append([name,(f'{v:.3f}%' if 'percent' in k else f'{v:.3f}',v)])
        else: rows.append([name,number(v) if isinstance(v,int) and not isinstance(v,bool) else str(v)])
    return rows
coverage_rows=flatten(S.get('coverage',{}))
method_list=''.join('<li>'+esc(x if isinstance(x,str) else json.dumps(x,ensure_ascii=False))+'</li>' for x in S.get('methodology',[]))
if not method_list:
    method_list=''.join('<li>'+esc(v)+'</li>' for v in provenance.get('rules',{}).values())

font_css=''
for variant,weight,style in [('regular',400,'normal'),('bold',700,'normal'),('italic',400,'italic')]:
    path=SITE/'public/fonts'/f'computer-editorial-serif-{variant}.ttf'
    blob=base64.b64encode(path.read_bytes()).decode()
    font_css+=f'@font-face{{font-family:"Computer Editorial Serif";font-style:{style};font-weight:{weight};font-display:swap;src:url(data:font/ttf;base64,{blob}) format("truetype")}}\n'
css='''
:root{--paper:#fff;--ink:#000;--muted:#404040;--line:#aaa;--font-editorial:'Computer Editorial Serif',Georgia,'Times New Roman',serif;color-scheme:light}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font-editorial);font-size:clamp(1.1rem,1.35vw,1.26rem);line-height:1.66}main{max-width:76rem;margin:0 auto;padding:3rem 1.4rem 5rem}header,.prose,.note-intro{max-width:44rem;margin-inline:auto}h1{font-size:clamp(2.5rem,5.5vw,4.4rem);line-height:1.04;font-weight:400;margin:.6em 0 .55em;letter-spacing:-.025em}h2{font-size:1.65em;line-height:1.2;font-weight:400;margin:2.1em 0 .8em}h3{font-size:1.18em;line-height:1.25;margin:1.7em 0 .7em}p{margin:1.05em 0}a{color:inherit;text-underline-offset:.13em;overflow-wrap:anywhere}a:hover{text-decoration-thickness:2px}a:focus-visible,button:focus-visible,input:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ink);outline-offset:3px}.dateline{font-size:1em;color:var(--muted)}.lead{font-size:1.14em;line-height:1.5}sup{font-size:.62em;vertical-align:super;margin-left:.2em}sup a{text-decoration:none}figure{margin:2.4rem auto;max-width:44rem;break-inside:avoid;page-break-inside:avoid}.chart{container-type:inline-size}.chart figcaption{text-align:center;font-weight:700;font-size:clamp(1.1rem,2.74cqw,1.3rem);line-height:1.35;margin-bottom:1.3rem}.legend{display:flex;flex-wrap:wrap;gap:.2em 1em;margin-bottom:1.25em;font-size:1em}.legend>span{display:flex;align-items:center;gap:.4em}.legend i{width:.8em;height:.8em;display:inline-block;border:1px solid var(--ink)}.solid,.bar-solid{background:var(--ink)}.open,.bar-open{background:var(--paper)}.bar-row{margin:1.5rem 0}.bar-label{margin-bottom:.35em}.bar-label>span{white-space:nowrap;color:var(--muted)}.bar-track{display:flex;height:2rem;border:1px solid var(--ink);width:100%;background:var(--paper)}.bar-track>span{display:block;min-width:0}.bar-empty{width:100%;text-align:center;height:auto;font-size:.85em;line-height:1.7}.bar-values{display:flex;justify-content:space-between;gap:1rem;line-height:1.35;margin-top:.45em}.bar-values>span:last-child{text-align:right}.table-scroll{max-width:100%;overflow:auto;margin:1.3rem 0 2rem;scrollbar-gutter:stable}.table-scroll.long{max-height:min(32rem,65vh)}table{width:100%;border-collapse:collapse;font:inherit;line-height:1.4;font-variant-numeric:tabular-nums}th,td{font:inherit;vertical-align:top;text-align:left;padding:.65em .55em;border-bottom:1px solid var(--line)}th{font-weight:700;background:var(--paper);position:sticky;top:0;z-index:1;border-bottom:1px solid var(--ink)}th:first-child,td:first-child{padding-left:0}th:last-child,td:last-child{padding-right:0}th button{font:inherit;line-height:inherit;border:0;padding:0;text-align:left;color:inherit;background:transparent;cursor:pointer}th button span{font-size:.8em;white-space:nowrap}.wide-table{min-width:100rem}.wide-table td{min-width:8rem}.wide-table td:last-child{overflow-wrap:anywhere}.wide-table td:nth-last-child(2){min-width:23rem}button,input{font:inherit}.controls{display:flex;align-items:center;flex-wrap:wrap;gap:.5em 1em;margin:1rem 0}.controls input{max-width:100%;padding:.3em .4em;border:1px solid var(--ink);background:var(--paper);color:var(--ink)}.controls output{color:var(--muted)}.print-only{display:none}.data-links{display:flex;flex-wrap:wrap;gap:.35em 1.2em;margin:1.2rem 0}.footnotes{border-top:1px solid var(--ink);margin-top:3rem}.footnotes>section{margin-bottom:2.5rem}.note-number{font-weight:700}.source-table td:nth-child(2){min-width:20rem}.source-table td:last-child{max-width:40rem}.technical{overflow-wrap:anywhere}.method-list{padding-left:1.4em}.method-list li{margin:.65em 0}.matrix-section{max-width:58rem;margin-inline:auto}.matrix-section .table-scroll{width:100%}.matrix-section table{min-width:36rem}.scope-key{max-width:44rem;margin-inline:auto}.scope-key dt{font-weight:700;margin-top:.8em}.scope-key dd{margin:.15em 0 1em}.small-note{color:var(--muted)}
@media(max-width:600px){main{padding:1.5rem 1rem 3rem}h1{font-size:2.65rem}.bar-values{display:block}.bar-values>span{display:block;margin-top:.2em}.bar-values>span:last-child{text-align:left}.bar-track{height:1.6rem}.legend{display:block}.legend>span{margin:.3em 0}.chart figcaption{font-size:1.1em}figure{margin:1.7rem auto}th,td{padding:.55em .5em}.table-scroll{margin-inline:0}.data-links{display:block}.data-links>a{display:block;margin:.35em 0}}
@page{size:A4;margin:16mm 17mm 18mm} @media print{html{scroll-behavior:auto}body{font-size:11pt;line-height:1.45;background:#fff;color:#000;-webkit-print-color-adjust:exact;print-color-adjust:exact}main{padding:0;max-width:none}header,.prose,.note-intro,.scope-key{max-width:none}h1{font-size:32pt;margin-top:0}h2{font-size:21pt;break-after:avoid}h3{break-after:avoid}.lead{font-size:12.5pt}.dateline{font-size:11pt}figure{max-width:none;margin:1.1rem 0;break-inside:avoid;page-break-inside:avoid}.chart figcaption{font-size:13pt}.legend{font-size:11pt;gap:.3em 1em;margin-bottom:.5em}.bar-row{margin:.7rem 0}.bar-track{height:1.2rem}.bar-label,.bar-values{font-size:11pt}.bar-values{display:flex}.bar-values>span:last-child{text-align:right}th,td,th button{font-size:inherit}.table-scroll,.table-scroll.long{overflow:visible;max-height:none;height:auto;scrollbar-gutter:auto}table,.matrix-section table{min-width:0;width:100%;table-layout:fixed}thead{display:table-header-group;break-inside:avoid;page-break-inside:avoid}th{position:static}tr{break-inside:avoid;page-break-inside:avoid}td,th{padding:.38em .35em;overflow-wrap:anywhere}th button span{display:none}a{color:#000}.interactive-only,.controls,.bulk-screen,.screen-only-table{display:none!important}.print-only{display:block}.table-scroll:not(.long){break-inside:avoid;page-break-inside:avoid}.footnotes{margin-top:1.5rem}.matrix-section{max-width:none}.matrix-section .table-scroll{break-inside:avoid}.print-link-list{overflow-wrap:anywhere}.data-links{margin:.6rem 0}p{orphans:3;widows:3}.footnotes>section{break-inside:auto}.technical{font-size:inherit}#coverage-table td:first-child{width:45%}}
'''
js='''
(()=>{'use strict';
const collator=new Intl.Collator('en',{numeric:true,sensitivity:'base'});
for(const table of document.querySelectorAll('table.sortable')){
 const body=table.tBodies[0]; const initial=new Map([...body.rows].map((row,i)=>[row,i]));
 for(const button of table.querySelectorAll('thead button'))button.addEventListener('click',()=>{
  const col=Number(button.dataset.column),th=button.closest('th');const asc=th.getAttribute('aria-sort')!=='ascending';
  for(const h of table.querySelectorAll('th')){h.removeAttribute('aria-sort');h.querySelector('span').textContent=' ↕'}
  th.setAttribute('aria-sort',asc?'ascending':'descending');button.querySelector('span').textContent=asc?' ↑':' ↓';
  const rows=[...body.rows];rows.sort((a,b)=>{
   const x=a.cells[col].dataset.sort??a.cells[col].textContent,y=b.cells[col].dataset.sort??b.cells[col].textContent;
   if(x===''||y==='')return x===''?(y===''?initial.get(a)-initial.get(b):1):-1;
   const numeric=/^-?\\d+(\\.\\d+)?$/.test(x)&&/^-?\\d+(\\.\\d+)?$/.test(y);
   const cmp=numeric?Number(x)-Number(y):collator.compare(x,y);return cmp?(asc?cmp:-cmp):initial.get(a)-initial.get(b);
  });body.append(...rows);
 });
}
for(const input of document.querySelectorAll('[data-search-table]')){
 const table=document.getElementById(input.dataset.searchTable),rows=[...table.tBodies[0].rows];
 const index=rows.map(row=>[row,row.textContent.toLocaleLowerCase()]); const output=document.getElementById(input.dataset.output);
 const run=()=>{const terms=input.value.trim().toLocaleLowerCase().split(/\\s+/).filter(Boolean);let visible=0;for(const [row,text]of index){row.hidden=!terms.every(t=>text.includes(t));if(!row.hidden)visible++}output.textContent=visible.toLocaleString()+' / '+rows.length.toLocaleString()+' records';};input.addEventListener('input',run);run();
}
})();
'''
def searchbox(tid,output,label_text): return f'<div class="controls"><label for="search-{tid}">{label_text}</label><input id="search-{tid}" type="search" data-search-table="{tid}" data-output="{output}" autocomplete="off"><output id="{output}" aria-live="polite"></output></div>'

def extra_matrices():
    out=[]
    for key,m in M.items():
        if key in ('strict_hiring','workplace_supervision','supervisor_or_recruitment_proxy') or not isinstance(m,dict) or 'cells' not in m:continue
        title=m.get('title') or key.replace('_',' ').capitalize()
        out.append('<section class="matrix-section"><h3>'+esc(title)+'</h3><p>'+esc(m.get('description',m.get('definition','Direct participation sensitivity; mentor roles alone do not supply a participant record.')))+f' Total pairs: {n(m["total"])}.</p>'+matrix_table(key,m)+conditional_table(key+'-conditional',m)+'</section>')
    return ''.join(out)

# Narrative is generated only from counts and evidence classifications in final artifacts.
lead=f'''<p class="lead">The Holoscope census contains {n(N)} people with {n(C['person_company_pairs'])} recorded AI-company employment relationships across {n(C['companies'])} companies. Public program and grant checks identify {n(expanded)} people with a program or personal funding record, {pct(expanded,N)} of the census. For {n(N-expanded)} people, the checked sources contain no such record.</p>
<p>The review attributes {n(strict['total'])} hiring decision{'s' if strict['total'] != 1 else ''} to a named decision-maker. It separately documents {n(workplace['total'])} workplace supervision pairs and retains {n(proxy['total'])} supervision or recruitment pairs in a broader comparison. These relationships describe a small, purposively selected subset of the database. Counted pairs cover {n(S['coverage']['unique_people_with_counted_pair'])} distinct employees, {pct(S['coverage']['unique_people_with_counted_pair'],N)} of the census. Hiring preferences across AI companies remain unresolved.</p>'''
body=f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>EA program and donor links in AI employment</title><style>{font_css}{css}</style></head><body><main>
<header><p class="dateline">Effort · Investigation · {esc(S['as_of'])}</p><h1>EA program and donor links in AI employment</h1></header>
<div class="prose">{lead}<p>The classification records listed participant or mentor roles in selected EA-funded programs, or a personal grant recommendation, award or donation documented in the checked sources. A missing record leaves the person’s beliefs and other affiliations unknown. Program participants and mentors appear separately in the evidence ledger. Employer funding remains an institutional relationship.</p></div>
{fig1}
<div class="prose"><p>The baseline contains {n(base)} people with a recorded marker. Additional source checks raise that total to {n(expanded)}. The measured share depends on which programs publish searchable directories, which people use matching public names, and which employment records Holoscope collected. Its records combine historical and current employment. Some mentor listings are undated. A website’s current navigation may advertise a future program without dating the listed mentor’s role.</p>
<h2>Hiring attribution</h2><p>The strict count requires a source that identifies the person who recruited or hired the employee. Recorded-marker classifications use the checked career histories; a missing record remains unresolved at the hiring date. Team supervision supplies a weaker connection: a manager may have inherited a team member, and a program affiliation may have begun after the hire. The strict hiring table contains {n(strict['total'])} qualifying pair{'s' if strict['total'] != 1 else ''}.</p>{strict_evidence}</div>
<section class="matrix-section"><h3>Verified hiring decisions</h3>{matrix_table('strict-hiring',strict)}{conditional_table('strict-conditional',strict)}</section>
<div class="prose"><p>The requested EA/non-EA split can be calculated only as a split between people with a recorded marker and people with no record in the checked sources. Each percentage below is a composition of the selected pair sample. A hiring-rate comparison would require the qualified applicant pool, selection outcomes and the decision-maker’s role for each hiring event.</p>
<h2>Supervision and recruitment records</h2><p>The broader comparison retains {n(proxy['total'])} documented supervision or recruitment pairs. The four cells are {n(a)} record/record, {n(b)} record/no-record, {n(c)} no-record/record and {n(d)} no-record/no-record, with supervisor or recruiter listed first. The evidence uses recorded lifetime affiliations where hiring-date classifications remain unresolved. Publicly visible researchers, team leaders and MATS-linked biographies supplied several of the leads; this selection concentrates the comparison around people with discoverable professional records.</p><p>{esc(proxy.get('description',''))}</p></div>
<section class="matrix-section"><h3>Workplace supervision</h3>{matrix_table('workplace-supervision',workplace)}{conditional_table('workplace-conditional',workplace)}</section>
<section class="matrix-section"><h3>Supervision or recruitment</h3>{matrix_table('supervision-recruitment',proxy)}</section>
{fig2}{fig3}
<div class="prose"><p>Among pairs whose supervisor or recruiter has a record, {pct(a,a+b)} of employees have a record and {pct(b,a+b)} have no record. Among pairs whose supervisor or recruiter has no record, the corresponding shares are {pct(c,c+d)} and {pct(d,c+d)}. Looking in the reverse direction, {pct(a,a+c)} of employees with a record have a supervisor or recruiter with a record; for employees with no record, that share is {pct(b,b+d)}. The tables retain every denominator, including undefined percentages where a group contains zero pairs.</p></div>
{extra_matrices()}
<div class="prose"><h2>Coverage and unresolved records</h2><p>The corpus search checks database records for all {n(N)} people. For {n(source_covered)} people, it also reaches captured source text; the remaining {n(N-source_covered)} have database-only coverage in that audit. The review separately checks named relationship leads against public sources. A complete individual web search remains unrecorded for most employees. Primary-source supervision or recruitment remains unresolved for {n(N-S['coverage']['unique_people_with_counted_pair'])} people.{esc(secondary_prose)}</p><p>The census follows the repository’s company classification and source-bearing employment records. It includes founder, intern and historical employment roles where a source supports the relationship. One person can contribute relationships at several companies. Counts by company therefore overlap.</p></div>
<section class="matrix-section"><h3>Recorded roles</h3>{table('recorded-roles',['Role','People','Evidence'],role_rows)}<p>Role counts overlap where one person has more than one type of record.</p></section>
<section><h3>Companies with the most recorded employees</h3>{table('company-coverage',['Company','People','With record','No record','Share with record','Evidence'],company_rows)}</section>
<div class="prose"><p>Every employee row retains source links and a supervision-review status. A source can establish employment while leaving the employee’s team and hiring decision-maker unresolved. Project coauthorship and external program mentorship remain separate from company line management. The pair ledger preserves those distinctions.</p><p>The grant review cross-checks personal records against public registers. A donor’s grant to a company, program or research institution supplies context for that institution. Each employee’s personal-funding marker requires a separately documented personal grant recommendation, award or donation. Ambiguous name matches remain excluded from the personal-funding marker. The additional named-research sensitivity includes {n(C.get('named_institutional_researchers',0))} people explicitly named in grants to institutions, bringing the census marker count to {n(C.get('named_research_funding_positive',expanded))}.</p></div>
<section id="employee-ledger" class="bulk-screen"><h2>Employee evidence ledger</h2>{searchbox('employees','employee-count','Search employees')}{table('employees',['Person','Company relationships','Baseline','After source checks','Recorded roles','Manager review','Corpus search coverage','Sources','Graph identity'],employee_rows,wide=True,long=True)}</section>
<section id="pair-ledger" class="bulk-screen"><h2>Supervision and hiring evidence ledger</h2>{searchbox('pairs','pair-count','Search relationships')}{table('pairs',['Employee','Supervisor / recruiter','Company','Relationship','Evidence status','Date','Source finding','Timing','Hiring verified','Proxy included','Sources'],pair_rows,wide=True,long=True)}</section>
<p class="print-only">The complete employee ledger and relationship ledger are embedded in the HTML report, with search, sorting and direct source links. The companion CSV and JSON files preserve every row.</p>
<div class="prose"><h2>Method and companion data</h2><p>The study uses the Holoscope snapshot exported on {esc(provenance.get('snapshot_exported_at','').split('T')[0])}, with source checks through {esc(S['as_of'])}. The live read-only database connection was unavailable during this review. The exported graph and normalized repository records supply the reproducible database census.</p><ol class="method-list">{method_list}</ol><p>The combined evidence supports counts of recorded professional and funding relationships. An estimate of preferential hiring remains dependent on verified hiring events, comparable candidate pools and dated affiliations.</p><div class="data-links">{link('data/employee_census_final.csv','Complete employee CSV')}{link('data/employee_census_final.json','Employee JSON')}{link('data/hiring_pairs_final.json','Hiring and supervision records')}{link('data/study_results.json','Study totals and calculations')}{link('data/aggregate_counts.csv','Aggregate counts CSV')}{link('data/conditional_percentages.csv','Conditional percentages CSV')}{link('data/unresolved_employees.csv','Unresolved employees CSV')}{link('data/report_source_ledger.csv','Source ledger CSV')}{link('scripts/build_report.py','Report build script')}</div></div>
<section class="footnotes"><div class="prose"><h2>Footnotes</h2></div>
<section id="note-1"><div class="note-intro"><p><span class="note-number">1.</span> Each bar contains the same {n(N)} resolved people. A filled segment counts people with a recorded program or personal funding marker; the open segment counts people for whom the checked sources contain no record. Program participation, mentoring and personal funding overlap, so their subtotals need separate treatment. The <a href="#employee-ledger">embedded employee ledger</a> and <a href="data/employee_census_final.json">complete data</a> supply the contributing records and citations. <a href="#ref-1" aria-label="Return to figure 1">Return ↑</a></p></div>{table('coverage-figure-data',['Source coverage','With record','No record','People','Share with record','Evidence'],[['Holoscope baseline',number(base),number(N-base),number(N),(pct(base,N),100*base/N),('<a href="#employee-ledger">Employee ledger</a>','Employee ledger')],['After source checks',number(expanded),number(N-expanded),number(N),(pct(expanded,N),100*expanded/N),('<a href="#employee-ledger">Employee ledger</a>','Employee ledger')]])}</section>
<section id="note-2"><div class="note-intro"><p><span class="note-number">2.</span> Row percentages divide each employee category by all selected pairs in its supervisor/recruiter category. The four cell counts derive from the final relationship ledger; the <a href="#pair-ledger">embedded ledger</a> gives each relationship type, source, review status and temporal uncertainty. The sample contains {n(proxy['total'])} pairs. <a href="#ref-2" aria-label="Return to figure 2">Return ↑</a></p></div>{conditional_table('all-conditional-data',proxy)}</section>
<section id="note-3"><div class="note-intro"><p><span class="note-number">3.</span> Column percentages divide each supervisor/recruiter category by all selected pairs in its employee category. The underlying four cells are the same as in footnote 2. These reverse conditional shares describe the same selected relationships. <a href="#ref-3" aria-label="Return to figure 3">Return ↑</a></p></div>{matrix_table('proxy-figure-data',proxy)}</section>
<section><div class="note-intro"><h3>Program funding evidence</h3><p>The grant register documents Coefficient Giving awards for MATS, BlueDot and SPAR. These awards establish institutional program funding; the employee markers separately record each person’s program role. Amounts are award records in US dollars, and each row links to the primary grant entry. The <a href="data/program_funding_evidence.json" rel="nofollow">complete program funding file</a> retains register fields and dates.</p></div>{table('program-funding',['Program','Award date','Amount (USD)','Purpose','Source'],funding_rows)}</section>
<section><div class="note-intro"><h3>Search coverage</h3><p>The coverage register records the source channels checked and their unresolved or excluded matches. Public directories provide uneven coverage of programs, periods and participant roles.</p></div>{table('coverage-table',['Measure / source','Coverage'],coverage_rows)}</section>
<section class="prose technical"><h3>Reproducibility</h3><p>Repository commit: {esc(provenance.get('git_commit',''))}. Snapshot SHA-256: {esc(provenance.get('snapshot_sha256',''))}. Graph revision: {esc(provenance.get('graph_revision',''))}.</p><p>{' · '.join(link(url,key.replace('_',' ')) for key,url in provenance.get('source_urls',{}).items())}. Further provenance: <a href="data/extraction_provenance.json">extraction record</a>.</p></section>
<section id="source-ledger" class="bulk-screen source-table"><h2>Source ledger</h2>{searchbox('sources','source-count','Search sources')}{table('sources',['ID','Source','Domain','Linked records','People / records'],ledger_rows,wide=True,long=True)}</section>
<p class="print-only">The complete source ledger contains {n(len(ledger_rows))} distinct URLs and is embedded in the HTML report. Companion: <a href="data/report_source_ledger.csv">data/report_source_ledger.csv</a>.</p>
</section></main><script>{js}</script></body></html>'''
body=re.sub(r'<a\b(?![^>]*\brel=)([^>]*)>',r'<a\1 rel="nofollow">',body)
(ROOT/'report.html').write_text(body)
(ROOT/'graphics').mkdir(exist_ok=True)
manifest=[
 {'id':'census-coverage','type':'100-percent-stacked-bars','finding':'Program and funding marker coverage in the employment census','inputs':['data/study_results.json','data/employee_census_final.json'],'transformation':'Unique resolved people; baseline and source-check marker booleans; percentage denominator is full census','unit':'people','output':'report.html#census-coverage','description':'Two bars compare the marker coverage of Holoscope with the expanded source checks.','caveats':'No record found leaves ideology unknown; career records mix historical and current employment.'},
 {'id':'employee-conditional','type':'100-percent-stacked-bars','finding':'Employee marker distribution given supervisor or recruiter marker','inputs':['data/study_results.json','data/hiring_pairs_final.json'],'transformation':'Row-conditional percentages of supervisor_or_recruitment_proxy; every nonzero bar sums to100%','unit':'pairs','output':'report.html#employee-conditional','description':'Employee records grouped by supervisor or recruiter record status.','caveats':'Convenience sample; lifetime affiliation proxy; zero denominator shown undefined.'},
 {'id':'supervisor-conditional','type':'100-percent-stacked-bars','finding':'Supervisor or recruiter marker distribution given employee marker','inputs':['data/study_results.json','data/hiring_pairs_final.json'],'transformation':'Column-conditional percentages of supervisor_or_recruitment_proxy','unit':'pairs','output':'report.html#supervisor-conditional','description':'Supervisor or recruiter records grouped by employee record status.','caveats':'Selected source-visible relationships; hiring decision-maker and date may remain unknown.'}
]
for graphic in manifest:
    graphic['source_ledger']='data/report_source_ledger.json'
(ROOT/'graphics/manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
(ROOT/'data/report_build_manifest.json').write_text(json.dumps({'people':N,'baseline_positive':base,'secondary_positive':expanded,'hiring_decisions':strict['total'],'workplace_pairs':workplace['total'],'proxy_pairs':proxy['total'],'embedded_employee_rows':len(employee_rows),'embedded_pair_rows':len(pair_rows),'source_urls':len(ledger_rows),'graphics':len(manifest),'self_contained':True,'input_sha256':{name:hashlib.sha256((DATA/name).read_bytes()).hexdigest() for name in ['study_results.json','employee_census_final.json','hiring_pairs_final.json','program_funding_evidence.json']},'report_sha256':hashlib.sha256(body.encode()).hexdigest()},indent=2)+'\n')
print(json.dumps({'report':str(ROOT/'report.html'),'people':N,'sources':len(ledger_rows),'bytes':len(body.encode())}))
