"""Descriptive follow-up on unchanged public outputs; no inference or rescoring."""
import csv
import hashlib
import io
import itertools
import re
from html import escape

MODELS = ['qwen3.5-9b', 'qwen3.8-27b', 'ministral3-8b']
LABELS = ['Qwen3.5 9B', 'Qwen3.8 27B', 'Ministral 3 8B']
OPTION_FIELDS = ['record_id', 'expected', 'offered_letters', 'question_choices_sha256']


def require(condition, message):
    if not condition:
        raise ValueError(message)


def csv_bytes(rows):
    stream = io.StringIO(newline='')
    writer = csv.DictWriter(stream, fieldnames=OPTION_FIELDS, lineterminator='\n')
    writer.writeheader(); writer.writerows(rows)
    return stream.getvalue().encode()


def project(sources):
    """Project all options and six receipts from complete pinned source objects."""
    schedules = []
    contexts = []
    for model in MODELS:
        source = sources[model]
        options = []
        for record in source['schedule']['records']:
            parts = record['input_parts']
            require(len(parts) == 1 and parts[0]['kind'] == 'text', 'Unexpected prompt structure')
            prompt = parts[0]['text']
            require(hashlib.sha256(prompt.encode()).hexdigest() == parts[0]['sha256'], 'Prompt digest differs')
            require(prompt.count('Question: ') == 1 and prompt.count('Reply with exactly one uppercase option letter and no other text.') == 1, 'Prompt boundary differs')
            body = prompt.split('Question: ', 1)[1].split('Reply with exactly one uppercase option letter and no other text.', 1)[0]
            require(body.count('Choices:\n') == 1, 'Choices boundary differs')
            choices = re.findall(r'^([A-J])\. (.+)$', body.split('Choices:\n', 1)[1], re.MULTILINE)
            letters = ''.join(k for k, _ in choices)
            require(3 <= len(letters) <= 10 and letters == 'ABCDEFGHIJ'[:len(letters)], 'Option list differs')
            require(record['expected_output'] in letters, 'Expected choice absent')
            options.append(dict(zip(OPTION_FIELDS, [record['record_id'], record['expected_output'], letters, hashlib.sha256(body.encode()).hexdigest()], strict=True)))
        schedules.append(options)
        for side in ['baseline', 'subject']:
            receipt = source[side + '_receipt']
            require(receipt['scoring_observation_sha256'] == source[side + '_manifest']['runtime_provider']['scoring_observation']['sha256'], 'Runtime observation binding differs')
            observations = source[side]['records']
            contexts.append({'model': model, 'side': side,
                'backend': receipt['backend']['name'], 'device': receipt['device']['device_kind'],
                'settings': receipt['execution_settings'],
                'observation_count': len(observations),
                'records_with_logprob': sum(r['logprob_sum'] is not None for r in observations),
                'records_with_token_count': sum(r['token_count'] is not None for r in observations)})
    require(schedules[0] == schedules[1] == schedules[2], 'Question bodies, options or keys differ across models')
    return csv_bytes(schedules[0]), contexts


def calculate(answer_data, option_data, contexts):
    rows = list(csv.DictReader(io.StringIO(answer_data.decode())))
    reader = csv.DictReader(io.StringIO(option_data.decode()))
    require(reader.fieldnames == OPTION_FIELDS, 'Option schema differs')
    options = list(reader)
    require(len(rows) == 1200 and len(options) == 400, 'Incomplete review inputs')
    ids = [r['record_id'] for r in options]
    require(len(set(ids)) == 400, 'Duplicate option record')
    for r in options:
        letters = r['offered_letters']
        require(3 <= len(letters) <= 10 and letters == 'ABCDEFGHIJ'[:len(letters)] and r['expected'] in letters, 'Invalid option list')
        require(re.fullmatch('[0-9a-f]{64}', r['question_choices_sha256']) is not None, 'Invalid question digest')
    by_model = {}
    for model in MODELS:
        records = [r for r in rows if r['model'] == model]
        require([r['record_id'] for r in records] == ids, 'Option order/coverage differs')
        require(all(r['expected'] == o['expected'] for r, o in zip(records, options, strict=True)), 'Answer keys differ')
        by_model[model] = {r['record_id']: r for r in records}
    require([(c['model'], c['side']) for c in contexts] == list(itertools.product(MODELS, ['baseline', 'subject'])), 'Runtime side coverage/order differs')
    for c in contexts:
        require(c['observation_count'] == 400 and c['settings']['max_output_tokens'] == 1, 'Retained runtime scope differs')
        require(c['records_with_logprob'] == c['records_with_token_count'] == 0, 'Retained measurement availability differs')
    validity = {}
    for model, records in by_model.items():
        invalid = {side: [rid for rid, o in zip(ids, options, strict=True) if records[rid][side] not in list(o['offered_letters'])] for side in ['baseline', 'subject']}
        normal = {side: [rid for rid in ids if int(records[rid][side].strip().upper() == records[rid]['expected']) != int(records[rid][side + '_score'])] for side in ['baseline', 'subject']}
        groups = {}
        for category in ['gain', 'loss', 'changed_wrong']:
            group = [r for r in records.values() if r['category'] == category]
            groups[category] = {'count': len(group), 'offered_option': sum(r['record_id'] not in invalid['subject'] for r in group), 'empty': sum(r['subject'] == '' for r in group), 'other_invalid': sum(r['record_id'] in invalid['subject'] and r['subject'] != '' for r in group)}
        validity[model] = {'invalid_ids': invalid, 'normalization_changed_score_ids': normal, 'changed_groups': groups}
    common = [rid for rid in ids if all(by_model[m][rid]['baseline_score'] == '1' for m in MODELS)]
    loss_ids = {m: [rid for rid in common if by_model[m][rid]['subject_score'] == '0'] for m in MODELS}
    affected = sorted(set.union(*(set(x) for x in loss_ids.values())))
    all_wrong = [rid for rid in ids if all(by_model[m][rid][s + '_score'] == '0' for m in MODELS for s in ['baseline', 'subject'])]
    all_correct = [rid for rid in ids if all(by_model[m][rid][s + '_score'] == '1' for m in MODELS for s in ['baseline', 'subject'])]
    same_wrong = [rid for rid in all_wrong if len({by_model[m][rid][s] for m in MODELS for s in ['baseline', 'subject']}) == 1]
    opposite = [{'record_id': rid, 'expected': by_model[MODELS[0]][rid]['expected'], 'models': {m: {key: by_model[m][rid][key] for key in ['baseline', 'subject', 'category']} for m in MODELS}} for rid in ids if {'gain', 'loss'} <= {by_model[m][rid]['category'] for m in MODELS}]
    return {'scope': 'Descriptive follow-up on 400 shared questions; six outputs per question are not independent trials.',
        'validity': validity, 'shared_baseline_correct': {'count': len(common), 'ids': common, 'loss_ids': loss_ids, 'affected_ids': affected, 'all_preserved': len(common) - len(affected)},
        'shared_errors': {'all_six_wrong_ids': all_wrong, 'same_wrong_option_ids': same_wrong, 'all_six_correct_ids': all_correct, 'mixed_correctness_ids': [rid for rid in ids if rid not in all_wrong and rid not in all_correct]},
        'opposite_direction_questions': opposite}


def charts(summary):
    def frame(title, description, height):
        return [f'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="{height}" viewBox="0 0 1200 {height}" role="img" aria-labelledby="title desc">', f'<title id="title">{escape(title)}</title><desc id="desc">{escape(description)}</desc>', f'<rect width="1200" height="{height}" fill="#f1f3f8"/><path d="M24 24V{height-24}" stroke="#b5c0d3"/>', '<g font-family="Arial, sans-serif" fill="#192235">', f'<text x="48" y="58" font-size="30" font-weight="700">{escape(title)}</text>']
    def text(parts, x, y, value, size=21, color='#192235', extra=''):
        parts.append(f'<text x="{x}" y="{y}" font-size="{size}" fill="{color}" {extra}>{escape(str(value))}</text>')
    def end(parts):
        return ('\n'.join(parts + ['</g></svg>'])+'\n').encode()
    outputs = {}
    groups = summary['validity'][MODELS[0]]['changed_groups']
    p = frame('All ten lost successes select another offered option', 'Qwen3.5: among 54 changed answers, gains have 17 valid choices; losses 10; changed wrong answers have 25 valid choices and two empty strings.', 550)
    text(p,48,94,'Qwen3.5 9B: the 54 changed strings, grouped by correctness and candidate answer format.',19,'#566176')
    for x, color, label in [(330,'#3048a5','An offered option'),(690,'#a02c24','Empty string')]:
        p.append(f'<rect x="{x}" y="120" width="18" height="18" fill="{color}"/>'); text(p,x+28,136,label,19)
    for i,(key,label) in enumerate([('loss','Correct → wrong'),('gain','Wrong → correct'),('changed_wrong','Changed, still wrong')]):
        y=183+i*88; g=groups[key]
        text(p,48,y+29,label)
        offered=g['offered_option']; empty=g['empty']; scale=24
        p.append(f'<rect x="330" y="{y}" width="{offered*scale}" height="42" fill="#3048a5"/>')
        text(p,330+offered*scale/2,y+28,offered,22,'#ffffff','text-anchor="middle"')
        if empty:
            p.append(f'<rect x="{330+offered*scale}" y="{y}" width="{empty*scale}" height="42" fill="#a02c24"/>')
            text(p,330+offered*scale+empty*scale/2,y+28,empty,22,'#ffffff','text-anchor="middle"')
        text(p,1050,y+28,f"{g['count']} total",20)
    for n in range(0,31,5): text(p,330+24*n,460,n,17,extra='text-anchor="middle"')
    text(p,690,491,'Number of changed answers',18,extra='text-anchor="middle"')
    text(p,48,528,'No scores change under whitespace trimming or case normalization. This does not identify failure causes.',18,'#566176')
    outputs['answer-validity.svg']=end(p)
    shared=summary['shared_baseline_correct']; affected=shared['affected_ids']
    p=frame('A shared starting point still produces different losses','All three baselines answer 137 questions correctly. Five different questions are lost: two for Qwen3.5, one for Qwen3.8 and two for Ministral; 132 remain correct in all three.',650)
    text(p,48,95,f"All three baselines were correct on {shared['count']} questions. These are all {len(affected)} with a later loss.",20,'#566176')
    for j,label in enumerate(LABELS): text(p,520+j*240,148,label,21,extra='text-anchor="middle" font-weight="700"')
    for i,rid in enumerate(affected):
        y=174+i*66; text(p,48,y+32,rid,22,extra='font-family="monospace"')
        for j,m in enumerate(MODELS):
            lost=rid in shared['loss_ids'][m]; x=420+j*240
            p.append(f'<rect x="{x}" y="{y}" width="200" height="48" rx="3" fill="{"#a02c24" if lost else "#fcfcfd"}" stroke="{"#a02c24" if lost else "#b5c0d3"}"/>')
            text(p,x+100,y+31,'Lost' if lost else 'Preserved',21,'#ffffff' if lost else '#192235','text-anchor="middle"')
    text(p,48,552,f"The other {shared['all_preserved']} questions stay correct in all three GGUF outputs.",23,extra='font-weight="700"')
    text(p,48,595,'Selected subset of the original 400 questions. Different losses do not establish independent causes.',18,'#566176')
    text(p,48,625,'Correctness follows the original answer key; no results or policy thresholds are changed.',18,'#566176')
    outputs['common-correct.svg']=end(p)
    e=summary['shared_errors']; wrong=len(e['all_six_wrong_ids']); same=len(e['same_wrong_option_ids'])
    p=frame(f'{wrong} questions stay wrong across all six outputs','Across 400 questions, 132 are correct in all six outputs, 176 have mixed correctness and 92 are wrong in all six. Of the 92, 30 have the same wrong option and 62 have different wrong outputs.',580)
    text(p,48,95,'Three models, each before and after the deployment change: six outputs for each question.',20,'#566176')
    data=[('Correct in all six',len(e['all_six_correct_ids']),'#3048a5'),('Mixed correctness',len(e['mixed_correctness_ids']),'#566176'),('Wrong in all six',wrong,'#a02c24')]
    for i,(label,n,color) in enumerate(data):
        y=144+i*78; text(p,48,y+29,label,22)
        if label == 'Wrong in all six':
            p.append('<defs><pattern id="different" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="#a02c24"/><path d="M0 8L8 0" stroke="#ffffff" stroke-width="1" opacity=".35"/></pattern></defs>')
            p.append(f'<rect x="310" y="{y}" width="{same*4}" height="42" fill="#a02c24"/>')
            p.append(f'<rect x="{310+same*4}" y="{y}" width="{(wrong-same)*4}" height="42" fill="url(#different)"/>')
            text(p,310+same*2,y+29,same,22,'#ffffff','text-anchor="middle" font-weight="700"')
            text(p,310+same*4+(wrong-same)*2,y+29,wrong-same,22,'#ffffff','text-anchor="middle" font-weight="700"')
        else:
            p.append(f'<rect x="310" y="{y}" width="{n*4}" height="42" fill="{color}"/>')
        text(p,326+n*4,y+29,n,22)
    for n in range(0,201,50): text(p,310+4*n,399,n,17,extra='text-anchor="middle"')
    text(p,710,431,'Number of questions (400 total)',18,extra='text-anchor="middle"')
    text(p,48,479,f'Within the {wrong} always-wrong questions:',22,extra='font-weight="700"')
    text(p,48,514,f'Solid: {same} questions with identical wrong outputs. Hatched: {wrong-same} with differing wrong outputs.',20)
    text(p,48,553,'Agreement follows the saved answer key. Six outputs are not six independent trials or a tested ensemble.',18,'#566176')
    outputs['shared-errors.svg']=end(p)
    return outputs
