#!/usr/bin/env python3
"""Render the frozen revised study, retaining every included pair in screen and print.

Inputs are read only. Calculations are independently reconstructed from pair membership;
any mismatch with a canonical matrix aborts the build. Output is standalone/offline HTML.
"""
from __future__ import annotations
import base64, collections, csv, hashlib, html, json, re
from pathlib import Path
from urllib.parse import urlparse

ROOT=Path(__file__).resolve().parents[1]
OLD=ROOT.parent
SITE=OLD.parents[1]/'effort-frontend'
DATA=ROOT/'data'
LABELS={
 'attributed_hiring':'Attributed hiring',
 'recruitment_involvement':'Recruitment involvement',
 'explicit_reporting':'Explicit reporting',
 'explicit_team_leadership':'Named team leadership',
 'project_leadership':'Project leadership',
 'inferred_team_or_department':'Named-unit inference',
 'project_advising':'Project advising',
 'indirect_department_chain':'Indirect departmental chains',
 'indirect_organizational_chain':'Indirect organizational chains',
 'team_project_department_local':'Team, project and named-unit leadership',
 'all_included':'All included pairs',
}
LAYERS=list(LABELS)[:-2]
ORDER=list(LABELS)
CHART_ORDER=['team_project_department_local','project_advising','indirect_department_chain','indirect_organizational_chain','all_included']
SHORT={**LABELS,'team_project_department_local':'Team, project and named unit','indirect_department_chain':'Departmental chains','indirect_organizational_chain':'Organizational chains','all_included':'All included'}

def esc(v): return html.escape(str(v if v is not None else ''),quote=True)
def num(v): return f'{int(v):,}' if float(v).is_integer() else f'{v:,.2f}'
def pct(n,d): return f'{n/d*100:.1f}%' if d else 'Unavailable'
def load(p): return json.loads(Path(p).read_text())
def first(d,*keys,default=None):
 for k in keys:
  if k in d and d[k] is not None:return d[k]
 return default

def items(v):return v if isinstance(v,list) else [v] if v is not None else []
def text_value(v):
 if isinstance(v,dict):return '; '.join(f'{k}: {text_value(x)}' for k,x in v.items() if x is not None)
 if isinstance(v,list):return '; '.join(dict.fromkeys(text_value(x) for x in v if x is not None))
 return str(v) if v is not None else ''

def sources(v):
 out={}
 def walk(x):
  if isinstance(x,str) and re.match(r'https?://',x):out.setdefault(x,{'url':x})
  elif isinstance(x,list):
   for y in x:walk(y)
  elif isinstance(x,dict):
   u=first(x,'url','source_url','href')
   if isinstance(u,str) and re.match(r'https?://',u):out.setdefault(u,dict(x,url=u))
   else:
    for k,y in x.items():
     if 'source' in k or 'url' in k:walk(y)
 walk(v);return list(out.values())

def a(url,label):return f'<a href="{esc(url)}" rel="nofollow">{esc(label)}</a>'
def source_links(v,label='Source'):
 ss=sources(v)
 return ' '.join(f'<a href="{esc(x["url"])}" rel="nofollow" title="{esc(first(x,"title","label",default=x["url"]))}" aria-label="{esc(label)} {i}: {esc(first(x,"title","label",default=urlparse(x["url"]).netloc))}">{esc(urlparse(x["url"]).netloc.removeprefix("www."))}</a>' for i,x in enumerate(ss,1))

def ref(n):return f'<sup><a href="#note-{n}" rel="nofollow" aria-label="Footnote {n}">{n}</a></sup>'
def table(tid,headers,rows,types=None,classes='',long=False):
 types=types or ['text']*len(headers)
 th=''.join(f'<th scope="col" data-type="{esc(typ)}"><button type="button" data-column="{i}">{esc(h)}<span class="sort-indicator" aria-hidden="true"> ↕</span></button></th>' for i,(h,typ) in enumerate(zip(headers,types)))
 body=[]
 for row in rows:
  cells=[]
  for cell in row:
   val,raw=cell if isinstance(cell,tuple) else (cell,re.sub('<[^>]+>',' ',str(cell)))
   cells.append(f'<td data-sort="{esc(raw)}">{val}</td>')
  body.append('<tr>'+''.join(cells)+'</tr>')
 return f'<div class="table-scroll{" long" if long else ""}" role="region" tabindex="0" aria-label="{esc(tid.replace("-"," "))}"><table id="{esc(tid)}" data-sortable class="{esc(classes)}"><thead><tr>{th}</tr></thead><tbody>{"".join(body)}</tbody></table></div>'

def normalize_layer(v):return str(v or '')

def bool_marker(v):
 if isinstance(v,bool):return v
 if v in (1,0):return bool(v)
 if isinstance(v,str):
  if v in ('recorded','record_found','ea_recorded','positive','EA','EA-evidenced','program_or_personal_funding_recorded'):return True
  if v in ('no_record','no_record_found','no_record_found_in_checked_sources','negative','non-EA','no_evidence_found'):return False
 return None

def person_id(p,endpoint):return first(p,endpoint+'_id',endpoint+'_person_id',endpoint+'_study_person_id')

def canonical_pair(raw,index,people):
 p=dict(raw)
 p['id']=str(first(p,'id','pair_id',default=f'row-{index+1}'))
 p['leader_id']=person_id(p,'leader') or first(p,'supervisor_id','hirer_id')
 p['employee_id']=person_id(p,'employee') or first(p,'subordinate_id','hiree_id')
 p['leader_name']=first(p,'leader_name','supervisor_name','hirer_name',default=p['leader_id'])
 p['employee_name']=first(p,'employee_name','subordinate_name','hiree_name',default=p['employee_id'])
 p['company_name']=first(p,'company_name','company','employer_name',default='Unspecified')
 p['company_id']=first(p,'company_id','employer_id',default=p['company_name'])
 p['layer']=normalize_layer(first(p,'layer','analysis_layer','relationship_layer','relationship_type'))
 p['tier']=first(p,'tier','uncertainty_tier','evidence_status',default='Unspecified')
 p['included']=first(p,'included','include_in_analysis','included_in_main_analysis',default=p['tier']!='candidate')
 evidence=items(p.get('evidence_records'))
 supports=items(p.get('supports'))+[s for e in evidence for s in items(e.get('supports'))]
 unit=first(p,'unit','unit_name','team','project')
 if not unit:
  units=[]
  for s in supports:
   m=s.get('matched_unit') or {};u=m.get('unit_employee') or m.get('unit_leader') or {};u=u.get('text') if isinstance(u,dict) else u
   if u:units.append(u)
  unit='; '.join(dict.fromkeys(units))
 p['unit_display']=text_value(unit) or ('Whole organization' if p['layer']=='indirect_organizational_chain' else 'Recorded workplace context')
 p['rule_display']=text_value(first(p,'inference_rule','rule','relationship_rule')) or '; '.join(dict.fromkeys(s.get('rule','') for s in supports if s.get('rule'))) or str(first(p,'summary','relationship_type',default='Source states the relationship'))
 dates=[]
 for s in supports:
  t=s.get('temporal') or {};start=t.get('overlap_start');end=t.get('overlap_end')
  if start or end:dates.append(str(start or 'unknown start')+(' to '+str(end or 'unknown end') if start!=end else ''))
 p['date_display']=(str(p['start'])+(' to '+str(p['end']) if p.get('end') and p['end']!=p['start'] else '') if p.get('start') else '') or text_value(first(p,'date_range','period','dates')) or '; '.join(dict.fromkeys(dates)) or text_value(first(p,'source_date','date','as_of','start_date',default='Date unresolved'))
 p['time_note']=text_value(first(p,'date_interpretation','temporal_note','temporal_status'))
 p['uncertainty']=text_value(first(p,'uncertainty','proof_boundary','uncertainty_note')) or '; '.join(dict.fromkeys(e['proof_boundary'] for e in evidence if e.get('proof_boundary')))
 if p.get('depends_on_open_role_continuity'):p['uncertainty']+=' Open-ended role continuity assumed.'
 if p.get('has_ORCID_employee_evidence'):p['uncertainty']+=' Employee source includes self-reported ORCID employment.'
 flags=items(p.get('record_quality_flags'))
 if flags:p['uncertainty']+=' '+', '.join(text_value(x) for x in flags)
 for endpoint in ('leader','employee'):
  person=people.get(p[endpoint+'_id'],{})
  value=first(p,endpoint+'_marker',endpoint+'_secondary_marker',endpoint+'_ea_marker')
  if value is None:value=first(person,'secondary_marker','marker','ea_marker','program_or_funding_marker')
  p[endpoint+'_marker']=bool_marker(value)
  if p['included']:assert p[endpoint+'_marker'] is not None,('Missing endpoint classification',p['id'],endpoint)
  p[endpoint+'_sources']=sources(first(p,endpoint+'_sources',endpoint+'_evidence_sources',default=[]))
  p[endpoint+'_role_display']=text_value(first(p,endpoint+'_role',endpoint+'_titles',endpoint+'_title',default='')) or '; '.join(dict.fromkeys(text_value(first(e,endpoint+'_role',endpoint+'_titles',endpoint+'_title',default='')) for e in evidence if first(e,endpoint+'_role',endpoint+'_titles',endpoint+'_title'))) 
  p[endpoint+'_marker_sources']=sources(first(p,endpoint+'_marker_sources',default=[person.get(k) for k in ['program_sources','external_program_sources','grant_sources','baseline_funding_sources','marker_sources','program_evidence','funding_evidence']]))
  p[endpoint+'_snippets']=items(first(p,endpoint+'_evidence_snippets',endpoint+'_snippets',default=[]))+[s for e in evidence for s in items(e.get(endpoint+'_evidence_snippets'))]
 return p

def count_matrix(rows,marker='marker',weighted=False):
 c=[0,0,0,0]
 for p in rows:
  c[(0 if p['leader_'+marker] else 2)+(0 if p['employee_'+marker] else 1)]+=p.get('closest_leader_weight',0) if weighted else 1
 return c

def conditional_rows(c,layer):
 aa,ab,ba,bb=c
 specs=[('Employee | leader with record','Employee with record',aa,aa+ab),('Employee | leader with record','Employee without record',ab,aa+ab),('Employee | leader without record','Employee with record',ba,ba+bb),('Employee | leader without record','Employee without record',bb,ba+bb),('Leader | employee with record','Leader with record',aa,aa+ba),('Leader | employee with record','Leader without record',ba,aa+ba),('Leader | employee without record','Leader with record',ab,ab+bb),('Leader | employee without record','Leader without record',bb,ab+bb)]
 return [{'layer':layer,'conditioning_group':g,'measured_group':m,'numerator':n,'denominator':d,'percentage':round(n/d*100,4) if d else None} for g,m,n,d in specs]

def matrix_values(o):
 if isinstance(o,list) and len(o)==4 and all(isinstance(x,(int,float)) for x in o):return list(o)
 if isinstance(o,dict):
  for keys in [('EA_EA','EA_nonEA','nonEA_EA','nonEA_nonEA'),('record_record','record_no_record','no_record_record','no_record_no_record'),('ee','en','ne','nn')]:
   if all(k in o for k in keys):return [o[k] for k in keys]
 return None

def verify_results(results,pairs,groups):
 """Find canonical matrix blocks by pair membership or layer key, validate supplied cells."""
 checked=[];ids={p['id']:p for p in pairs}
 def walk(obj,path=()):
  if not isinstance(obj,dict):return
  member_ids=first(obj,'pair_ids','included_pair_ids')
  supplied=matrix_values(first(obj,'cells','matrix','counts'))
  if supplied is None:supplied=matrix_values(obj)
  if supplied is not None:
   group=None
   if isinstance(member_ids,list) and all(x in ids for x in member_ids):group=[ids[x] for x in member_ids]
   elif path and normalize_layer(path[-1]) in groups:group=groups[normalize_layer(path[-1])]
   if group is not None:
    actual=count_matrix(group,obj.get('marker','marker'),obj.get('weighting','per_pair')!='per_pair');assert all(abs(a-b)<1e-6 for a,b in zip(actual,supplied)),('Canonical matrix mismatch',path,actual,supplied);checked.append('.'.join(path))
  for k,v in obj.items():
   if isinstance(v,dict):walk(v,path+(k,))
 walk(results)
 return checked

def count_figure(groups):
 maxv=max(len(groups[k]) for k in CHART_ORDER[:-1]) or 1
 bars=''.join(f'<div class="count-row"><div class="count-name">{esc(LABELS[k])}</div><div class="count-measure"><div class="count-track"><div class="count-fill" style="width:{len(groups[k])/maxv*100:.6f}%"></div></div><div class="number">{num(len(groups[k]))}</div></div></div>' for k in CHART_ORDER[:-1])
 return '<figure class="figure" id="relation-counts" data-graphic-key="relation-counts" aria-labelledby="relation-counts-title"><figcaption id="relation-counts-title">Included pairs by relationship scope'+ref(1)+'</figcaption>'+bars+'</figure>'

def conditional_figure(groups,direction):
 eid='employee-conditional' if direction=='employee' else 'leader-conditional'
 title='Employee records, grouped by leader' if direction=='employee' else 'Leader records, grouped by employee'
 who='Leader' if direction=='employee' else 'Employee'
 parts=[]
 for k in CHART_ORDER:
  aa,ab,ba,bb=count_matrix(groups[k]);spec=[(aa,aa+ab),(ba,ba+bb)] if direction=='employee' else [(aa,aa+ba),(ab,ab+bb)]
  bars=[]
  for (n,d),label in zip(spec,['With record','No record']):
   w=n/d*100 if d else 0
   bars.append(f'<div class="share-line"><span>{esc(label)}</span><div class="share-track{" missing" if not d else ""}" role="img" aria-label="{esc(who)} {esc(label.lower())}: {esc(pct(n,d))} recorded; {n} of {d} pairs"><span class="share-fill" style="width:{w:.8f}%"></span><span class="share-rest" style="width:{100-w:.8f}%"></span></div><span class="number">{esc(pct(n,d))}</span></div>')
  parts.append(f'<div class="conditional-group"><div class="conditional-name">{esc(SHORT[k])}</div>{"".join(bars)}</div>')
 note=3 if direction=='employee' else 4
 return f'<figure class="figure" id="{eid}" data-graphic-key="{eid}" aria-labelledby="{eid}-title"><figcaption id="{eid}-title">{esc(title)}{ref(note)}</figcaption><div class="legend"><span><i></i> With recorded program role or personal funding</span><span><i class="empty"></i> No record found</span></div><div class="axis"><span>0%</span><span>100%</span></div>{"".join(parts)}</figure>'

def endpoint_cell(p,endpoint):
 role=p[endpoint+'_role_display'];ss=p[endpoint+'_sources'];mark=p[endpoint+'_marker_sources']
 chunks=[f'<p>{esc(role)}</p>' if role else '',f'<p class="source-links">{source_links(ss,"Workplace")}</p>']
 if mark:chunks.append(f'<p class="source-links">Program / grant: {source_links(mark,"Record")}</p>')
 # The full evidence JSON preserves repeated import metadata verbatim. In the
 # table, expose distinct source locations and compact role-record values.
 detail=[]
 locators=list(dict.fromkeys(str(x['locator']) for x in ss if x.get('locator')))
 for loc in locators:detail.append(esc(loc))
 snippets=[]
 for entry in p[endpoint+'_snippets']:
  value=first(entry,'snippet','text','quote') if isinstance(entry,dict) else entry
  if not value:continue
  try:
   fields=json.loads(value)
   value='; '.join(str(v) for k,v in fields.items() if k in ('role_title','department','title','start','end','as_of') and v)
  except (json.JSONDecodeError,TypeError):pass
  if value and value!=role:snippets.append(str(value))
 if snippets:detail.append('Role record: '+esc('; '.join(dict.fromkeys(snippets))))
 if detail:chunks.append('<details><summary>Evidence location</summary><div class="detail-content">'+''.join(f'<p>{v}</p>' for v in detail)+'</div></details>')
 return ''.join(chunks)

def pair_table(pairs):
 headers=['Leader and employee','Employer and unit','Relationship and period','Leader evidence','Employee evidence']
 th=''.join(f'<th scope="col" data-type="text"><button type="button" data-column="{i}">{h}<span class="sort-indicator" aria-hidden="true"> ↕</span></button></th>' for i,h in enumerate(headers))
 rows=[]
 for i,p in enumerate(pairs,1):
  marker=lambda e:'Record found' if p[e+'_marker'] else 'No record found'
  names=f'<p class="row-key">R{i:04d}</p><p class="pair-names">{esc(p["leader_name"])}<br>→ {esc(p["employee_name"])}</p><p>Leader: {marker("leader")}<br>Employee: {marker("employee")}</p>'
  company=f'<p class="key">{esc(p["company_name"])}</p><p>{esc(p["unit_display"])}</p>'
  rule_labels={
 'attributed_hiring':'Source attributes recruitment or hiring.',
 'recruitment_involvement':'Source identifies recruitment involvement.',
 'explicit_reporting':'Source identifies reporting relationship.',
 'explicit_team_leadership':'Named leader and team member.',
 'project_leadership':'Named project lead joined to contributor in that remit.',
 'project_advising':'Named project adviser joined to contributor.',
 'inferred_team_or_department':'Shared named unit and leadership title.',
 'indirect_department_chain':'Department head → department member.',
 'indirect_organizational_chain':'Organization executive → employee record.'}
  relation=f'<p class="key">{esc(LABELS[p["layer"]])}</p><p>{esc(p["tier"]).replace("_"," ")}</p><p>Recorded: {esc(p["date_display"])}</p><p>{esc(rule_labels[p["layer"]])}</p>'
  notes=[]
  if p.get('depends_on_open_role_continuity'):notes.append('Role continuity assumed.')
  if p.get('has_ORCID_employee_evidence'):notes.append('ORCID self-report.')
  if p['layer'] in ('indirect_department_chain','indirect_organizational_chain'):notes.append('Intermediary managers unresolved.')
  if p['layer'] in ('project_leadership','project_advising','explicit_team_leadership'):notes.append('Project/team role date; selection date unresolved.')
  if p['time_note'] in ('overlap_possible_dates_incomplete','role_dates_unknown'):notes.append('Dates incomplete.')
  if notes:relation+='<details><summary>Timing and uncertainty</summary><div class="detail-content"><p>'+esc(' '.join(notes))+'</p></div></details>'
  values=[(names,p['leader_name']+' '+p['employee_name']),(company,p['company_name']+' '+p['unit_display']),(relation,p['layer']+' '+p['date_display']),(endpoint_cell(p,'leader'),p['leader_role_display']+' '+text_value(p['leader_sources'])),(endpoint_cell(p,'employee'),p['employee_role_display']+' '+text_value(p['employee_sources']))]
  search=' '.join([p['leader_name'],p['employee_name'],p['company_name'],p['unit_display'],p['rule_display'],p['tier'],p['id']]).lower()
  rows.append(f'<tr id="pair-{i:04d}" data-pair-id="{esc(p["id"])}" data-layer="{esc(p["layer"])}" data-company="{esc(p["company_id"])}" data-search="{esc(search)}">'+''.join(f'<td data-sort="{esc(raw)}">{val}</td>' for val,raw in values)+'</tr>')
 companies=sorted({(p['company_id'],p['company_name']) for p in pairs},key=lambda x:x[1].lower())
 controls=f'<div class="controls"><label>Search people or units<input id="pair-search" type="search" aria-controls="pairs"></label><label>Relationship scope<select id="pair-layer" aria-controls="pairs"><option value="">All scopes</option>'+''.join(f'<option value="{k}">{esc(SHORT[k])}</option>' for k in LAYERS)+'</select></label><label>Employer<select id="pair-company" aria-controls="pairs"><option value="">All employers</option>'+''.join(f'<option value="{esc(cid)}">{esc(name)}</option>' for cid,name in companies)+'</select></label><button id="pair-reset" type="button">Reset filters</button><output id="pair-count" aria-live="polite"></output></div>'
 return controls+'<div class="table-scroll long" role="region" tabindex="0" aria-label="Complete leader and employee evidence table"><table id="pairs" class="pairs" data-sortable><thead><tr>'+th+'</tr></thead><tbody>'+''.join(rows)+'</tbody></table></div>'

def main():
 pair_path=DATA/'leadership_pairs_final.json';result_path=DATA/'study_results.json'
 raw=load(pair_path);results=load(result_path)
 if isinstance(raw,dict):raw=first(raw,'pairs','rows','records')
 people={};person_path=DATA/'revised_person_roster.json'
 if not person_path.exists():person_path=OLD/'data/employee_census_final.json'
 for p in load(person_path):people[first(p,'person_id','id','study_person_id')]=p
 for p in load(DATA/'added_person_program_evidence_reviewed.json') if (DATA/'added_person_program_evidence_reviewed.json').exists() else []:
  pid=first(p,'person_id','id');people.setdefault(pid,{}).update({'secondary_marker':True,'program_evidence':p})
 pairs=[canonical_pair(p,i,people) for i,p in enumerate(raw)]
 pairs=[p for p in pairs if p['included']]
 assert len(pairs)>100,('Expected expanded leadership study',len(pairs))
 assert len({p['id'] for p in pairs})==len(pairs),'Duplicate included pair IDs'
 assert all(p['layer'] in LAYERS for p in pairs),collections.Counter(p['layer'] for p in pairs)
 assert all(p['leader_sources'] and p['employee_sources'] for p in pairs),'Missing workplace endpoint sources'
 pairs.sort(key=lambda p:(ORDER.index(p['layer']),p['company_name'].casefold(),p['leader_name'].casefold(),p['employee_name'].casefold(),p['id']))
 groups={k:[p for p in pairs if p['layer']==k] for k in LAYERS};groups['all_included']=pairs
 ids={p['id']:p for p in pairs}
 groups['team_project_department_local']=[ids[x]for x in results['matrices']['team_project_department_local']['pair_ids']]
 checked=verify_results(results,pairs,groups)
 matrices={k:count_matrix(v) for k,v in groups.items()}
 conditionals=[r for k in ORDER for r in conditional_rows(matrices[k],k)]
 companies={p['company_id'] for p in pairs};employees={p['employee_id'] for p in pairs};leaders={p['leader_id'] for p in pairs}
 all_sources={s['url'] for p in pairs for endpoint in ('leader','employee') for s in p[endpoint+'_sources']+p[endpoint+'_marker_sources']}
 total=len(pairs);local=len(groups['team_project_department_local']);advising=len(groups['project_advising']);department=len(groups['indirect_department_chain']);organization=len(groups['indirect_organizational_chain'])
 ea,ena,nea,nna=matrices['team_project_department_local']
 intro=f'''<header><p class="dateline">Holoscope investigation · Revised 9 October 2026</p><h1>EA program and funding records in AI leadership</h1><p class="lead">The expanded study maps {num(total)} leader and employee pairs at {num(len(companies))} employers. Its local leadership layer covers {num(local)} reporting, team, project and named-unit relationships. The broader union adds {num(advising)} project-advising relationships, {num(department)} indirect departmental chains and {num(organization)} indirect organizational chains.{ref(1)}</p></header>
 <div class="prose"><p>The revised employee census contains {num(results['cohort']['people'])} people, including {num(results['cohort']['secondary_positive'])} with a recorded program role or personal funding. The relationship table covers {num(len(employees))} distinct employees and {num(len(leaders))} leaders. Among the original {num(results['cohort']['original_people'])} people, {num(results['coverage']['original_people_with_leader'])} have an included leadership relationship and {num(results['coverage']['original_people_without_leader'])} have no included relationship in the current evidence.</p><p>The relationship scope matters. Named team and project sources place people in the same working group; directed project links can be inferred from separate leadership and contribution statements. Project advising has its own layer. Departmental and organizational chains connect employees to broader leadership. The table preserves those distinctions for every pair, together with the role records and timing assumptions that support it.</p>
 <p>All {num(results['cohort']['people'])} people received a secondary check against the captured public program and donor records. The marker records a person's documented role in a program run by or funded by EA organizations, including a mentor role, or personal funding from the checked EA donors. "No record found" means the reviewed database and public-source checks supplied no accepted record. Each direction of the comparison uses its own denominator.{ref(2)}</p>
 <p>Within the local leadership layer, employees have a recorded program role or personal funding in {pct(ea,ea+ena)} of pairs led by someone with a record ({num(ea)} of {num(ea+ena)}), compared with {pct(nea,nea+nna)} of pairs led by someone without a record ({num(nea)} of {num(nea+nna)}). The totals describe the observed relationships and their evidence categories. Attributing preferential selection requires hiring responsibility, the timing of the marker, and a relevant pool of candidates.{ref(3)}</p>
 <p class="data-links">{a('data/leadership_pairs_final.json','Full evidence JSON')}{a('data/leadership_table.csv','Readable pair CSV')}{a('data/study_results.json','Canonical results')}{a('data/report_display_data.json','Chart calculations')}{a('data/revised_person_roster.json','Every person and marker source')}{a('data/employee_company_coverage.csv','All employee/employer coverage')}{a('data/unresolved_leadership_table.csv','Unresolved leadership records')}</p></div>'''
 count_rows=[]
 for k in ORDER:
  c=matrices[k];count_rows.append([esc(LABELS[k]),(num(len(groups[k])),len(groups[k])),(num(c[0]),c[0]),(num(c[1]),c[1]),(num(c[2]),c[2]),(num(c[3]),c[3]),a('#pairs','Included evidence rows')])
 conditional_tables=[]
 for direction,note in [('Employee',3),('Leader',4)]:
  rr=[r for r in conditionals if r['conditioning_group'].startswith(direction+' |')]
  rows=[[esc(LABELS[r['layer']]),esc(r['conditioning_group'].split(' | ')[1].capitalize()),esc(r['measured_group']),(num(r['numerator']),r['numerator']),(num(r['denominator']),r['denominator']),(pct(r['numerator'],r['denominator']),r['percentage'] if r['percentage'] is not None else ''),a('#pairs','Pair evidence')] for r in rr]
  conditional_tables.append(table(f'conditional-data-{note}',['Scope','Condition','Measured group','Pairs','Denominator','Share','Sources'],rows,['text','text','text','number','number','number','text'],'compact-table',True))
 note1=f'<section id="note-1"><p class="note-intro"><span class="note-number">1.</span> Each included row is one directed leader and employee pair within an employer. The canonical revision assigns overlapping evidence to one displayed relationship scope. The nine relationship-type rows below reproduce the included table. The local leadership subtotal overlaps those rows; the final row is their full union. A person can appear in multiple pairs. Counts therefore describe relationships. {a("#relation-counts","Return to figure")}</p>'+table('matrix-data',['Scope','Total','Record / record','Record / no record','No record / record','Neither has a record','Sources'],count_rows,['text','number','number','number','number','number','text'],'compact-table')+'</section>'
 note2='<section id="note-3"><p class="note-intro"><span class="note-number">3.</span> Employee shares condition on the leader category. For record / record = a, record / no record = b, no record / record = c, and neither = d, employee-record shares are a/(a+b) and c/(c+d). The complementary shares use b and d. An empty denominator is unavailable. Every figure uses the same frozen pair ledger. '+a('#employee-conditional','Return to figure')+'</p>'+conditional_tables[0]+'</section>'
 note3='<section id="note-4"><p class="note-intro"><span class="note-number">4.</span> Leader shares condition on the employee category. Leader-record shares are a/(a+c) and b/(b+d); complementary shares use c and d. These pair-weighted denominators preserve each leadership relationship. '+a('#leader-conditional','Return to figure')+'</p>'+conditional_tables[1]+'</section>'
 method='''<section id="note-2"><div class="note-intro"><p><span class="note-number">2.</span> The revised study inventories every record in the frozen Holoscope native snapshot and graph export, including structured employment and role records, organization subdivisions, research arrays and legacy source packets. The saved-source scan covers 8,943 files; the structural inventory contains 5,505 role records. Public-web verification targets candidate identities and relationships, while program and donor name/alias checks cover the full person roster. It resolves employer subdivisions before joining role records, preserves graph identity IDs and records reviewed aliases. The old investigation remains archived in the parent directory. <a href="../data/extraction_provenance.json" rel="nofollow">Snapshot provenance</a> and <a href="data/structural_audit_summary.json" rel="nofollow">the structural audit</a> record the source inventories. The <a href="scripts/build_report.py" rel="nofollow">report builder</a> independently recomputes the canonical matrices before rendering.</p>
 <p>Explicit sources identify reporting, team or project leadership. A named-unit inference joins a leader role to an employee role in the same employer and a specific shared unit. Broader departmental and organizational chains retain their indirect scope. The structural audit separates unresolved candidates, rejects explicitly disjoint dates, and records open-ended role continuity assumptions. Pair-level uncertainty and endpoint source quality remain visible in the evidence table.</p>
 <p>Public program and donor registers supplement the database classifications. Accepted mentor roles, participant roles and personal grants retain distinct source records. Funding for an employer or an unrelated coauthor supplies a different relationship and does not automatically change a person's marker. The marker can refer to a role or grant recorded after the workplace relationship; a time-qualified hiring analysis requires an established selection date and earlier program or funding evidence.</p>
 <p class="data-links"><a href="data/leadership_pairs_final.csv" rel="nofollow">Full nested evidence CSV</a><a href="README.md" rel="nofollow">Package documentation</a><a href="data/employee_company_coverage.json" rel="nofollow">Full coverage JSON</a><a href="data/entity-graph.json" rel="nofollow">Entity graph</a><a href="data/nodes.csv" rel="nofollow">Graph nodes CSV</a><a href="data/edges.csv" rel="nofollow">Graph edges CSV</a><a href="data/source_ledger.json" rel="nofollow">Source ledger JSON</a><a href="data/source_ledger.csv" rel="nofollow">Source ledger CSV</a></p><p>ORCID employment entries are self-reported. Holoscope also contains imported aggregator records with varying review status. The report carries those source distinctions through the inferred relationships. The secondary lookup uses 285 captured program sources and 6,926 donor-register records, supplemented by the reviewed primary sources for newly identified people. Repeated links to one executive and overlapping teams make the pair observations dependent; their percentages describe this source collection.</p></div></section>'''
 sensitivity_keys=['no_open_role_continuity','participant_or_personal_grant','closest_leader_per_employee_company']
 sensitivity_labels={'no_open_role_continuity':'Role evidence independent of open-ended continuity','participant_or_personal_grant':'Participants or personal grant recipients','closest_leader_per_employee_company':'Closest leader, weighted once per employee/employer'}
 sensitivity_matrix=[];sensitivity_cond=[]
 for key in sensitivity_keys:
  m=results['matrices'][key];c=m['cells'];title=sensitivity_labels[key]
  sensitivity_matrix.append([esc(title),(num(sum(c)),sum(c))]+[(num(x),x) for x in c]+[a('#pairs','Pair sources')])
  for r in conditional_rows(c,key):
   sensitivity_cond.append([esc(title),esc(r['conditioning_group']),esc(r['measured_group']),(num(r['numerator']),r['numerator']),(num(r['denominator']),r['denominator']),(pct(r['numerator'],r['denominator']),r['percentage'] if r['percentage'] is not None else ''),a('#pairs','Pair sources')])
 sensitivity_table=table('sensitivity-matrix',['Rule','Total weight','Record / record','Record / no record','No record / record','Neither recorded','Sources'],sensitivity_matrix,['text','number','number','number','number','number','text'],'compact-table')+table('sensitivity-conditional',['Rule','Condition','Measured group','Weight','Denominator','Share','Sources'],sensitivity_cond,['text','text','text','number','number','number','text'],'compact-table',True)
 note5='<section id="note-5"><p class="note-intro"><span class="note-number">5.</span> Three alternative specifications retain different evidence or counting rules. The continuity filter uses roles supported independently of an assumed open-ended tenure. The participant-or-grant marker removes mentor-only program connections. The closest-leader analysis gives each employee/employer unit total weight one, divided equally among its best-supported leaders; resulting cell weights can be fractional. The full evidence file retains each pair’s closest-leader weight. Percentages below use those same weights. '+a('data/study_results.json','Canonical matrix memberships and weights')+'</p>'+sensitivity_table+'</section>'
 sensitivity_intro='<div class="prose"><p>The sensitivity tables also restrict the marker to participants and personal grant recipients, remove role links that depend on open-ended continuity, and give each employee/employer unit equal total weight across its closest supported leaders. These comparisons show how the counting rule changes the observed distribution.'+ref(5)+'</p></div>'
 ledger='<section id="ledger"><div class="prose"><h2>Every included relationship</h2><p>Read each row from leader to employee. The scope states the level of authority supported by the record. Open the evidence details for source locations and record excerpts. Dates identify source observations, project work or overlap inferred from role records. Screen filters and sorting affect only this table; the print version includes every row and expands all details.</p></div>'+pair_table(pairs)+'</section>'
 body=intro+count_figure(groups)+conditional_figure(groups,'employee')+conditional_figure(groups,'leader')+sensitivity_intro+ledger+'<section class="footnotes"><div class="prose"><h2>Footnotes</h2></div>'+note1+method+note2+note3+note5+'</section>'
 fontcss=''
 for suffix,weight,style in [('regular',400,'normal'),('bold',700,'normal'),('italic',400,'italic')]:
  b64=base64.b64encode((SITE/f'public/fonts/computer-editorial-serif-{suffix}.ttf').read_bytes()).decode()
  fontcss+=f'@font-face{{font-family:"Computer Editorial Serif";font-weight:{weight};font-style:{style};font-display:swap;src:url(data:font/ttf;base64,{b64}) format("truetype")}}\n'
 css=(ROOT/'scripts/report.css').read_text();js=(ROOT/'scripts/report.js').read_text()
 doc='<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>EA program and funding records in AI leadership</title><style>'+fontcss+css+'</style></head><body><main>'+body+'</main><script>'+js+'</script></body></html>'
 (ROOT/'report.html').write_text(doc)
 display={'pair_count':total,'distinct_employees':len(employees),'distinct_leaders':len(leaders),'employer_count':len(companies),'matrices':matrices,'conditionals':conditionals,'sensitivity_matrices':{k:results['matrices'][k] for k in sensitivity_keys},'layers':{k:{'label':LABELS[k],'count':len(v),'pair_ids':[p['id'] for p in v]} for k,v in groups.items()}}
 (DATA/'report_display_data.json').write_text(json.dumps(display,indent=2,ensure_ascii=False)+'\n')
 inputs=[pair_path,result_path,person_path]
 build={'embedded_pair_rows':total,'source_urls':len(all_sources),'graphics':3,'matrices_checked':checked,'input_sha256':{str(p.relative_to(OLD)):hashlib.sha256(p.read_bytes()).hexdigest() for p in inputs},'report_sha256':hashlib.sha256(doc.encode()).hexdigest(),'print_contract':'Every included pair and evidence disclosure renders in print; screen filters do not remove print rows.'}
 (DATA/'report_build_manifest.json').write_text(json.dumps(build,indent=2)+'\n')
 manifest={'generated_from':['data/leadership_pairs_final.json','data/study_results.json'],'graphics':[]}
 for key in ['relation-counts','employee-conditional','leader-conditional']:
  manifest['graphics'].append({'key':key,'selector':'#'+key,'output':'report.html','input':'data/report_display_data.json','transformation':'Distinct canonical included pair records; local leadership, project advising, departmental chains and organizational chains are disjoint'  if key=='relation-counts' else 'Pair-weighted conditional shares; record counts divided by the conditioning category total','source_records':'#pairs','data_footnote':'#note-'+str({'relation-counts':1,'employee-conditional':3,'leader-conditional':4}[key]),'uncertainty':'Layers preserve explicit links, unit inferences and indirect chains. No-record status preserves unknown participation/funding history.'})
 (ROOT/'graphics/manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
 from html.parser import HTMLParser
 class Links(HTMLParser):
  def __init__(self):super().__init__();self.paths=set()
  def handle_starttag(self,tag,attrs):
   for k,v in attrs:
    if k in ('href','src') and v and not v.startswith(('#','http://','https://','data:','mailto:')):self.paths.add(v.split('#')[0])
 parser=Links();parser.feed(doc);dependencies=[]
 for href in ['report.html']+sorted(parser.paths):
  target=(ROOT/href).resolve();assert target.is_file(),('Missing public report dependency',href)
  dependencies.append({'relative_href':href,'source_path':str(target),'bytes':target.stat().st_size,'sha256':hashlib.sha256(target.read_bytes()).hexdigest()})
 (ROOT/'verification/public_dependency_manifest.json').write_text(json.dumps({'report_sha256':build['report_sha256'],'canonical_inputs':build['input_sha256'],'dependencies':dependencies,'runtime_external_assets':[]},indent=2)+'\n')

 print(json.dumps({'report':str(ROOT/'report.html'),'pairs':total,'layers':{k:len(v) for k,v in groups.items()},'canonical_matrices_checked':checked}))

if __name__=='__main__':main()
