"""Requirement-denominator A matrix. Static hints never become runtime types.

Pure local inventory: no observer, transport, runtime session or current-game IO.
"""
from collections import Counter
from copy import deepcopy
import hashlib
import json
from .paths import root
from .arbeit_graph import HINT_OPERATIONS, compile_all, load_catalog
from .arbeit_coverage import PROFILES, RUNTIME_PROFILES


def _rule(key,title,ops,path,anchor,condition,needed,*,targets=(),kinds=(),runtime_types=(),all_objectives=False):
    return dict(id=key,title=title,status='open',category='A',impact='conditional',
        affected_ops=list(ops),target_kinds=list(targets),display_kinds=list(kinds),
        runtime_types=list(runtime_types),all_objectives=all_objectives,
        condition=condition,required_evidence=needed,
        resolution_locations=[path],evidence=[dict(path=path,anchor=anchor)])


def base_rules():
    return [
      _rule('material-acquisition','재료 부족 시 지원 밖 공급 방법',
        ['inventory.hold','life.craft','life.alter'],'masukja2/supply_selection.py','def collect_supply_candidates(',
        '현재 exact domain/item의 usable stock이 부족하고 기존 gather/alter/craft 후보 및 동일 회차 전용 획득으로 충족할 수 없는 경우',
        ['exact recipe material ID/domain/quantity','현재 usable stock·잠금·목표 예약','지원된 현재 획득 source와 입력·효과'],kinds=['hold','craft','alter']),
      _rule('fake-acquisition-family','다른 전용 품목 획득 타입',
        ['inventory.hold','objective.inspect','objective.unresolved'],'masukja2/objective_inventory_policy.py','def bind_fake_acquisition(',
        '같은 회차 GainFakeItemByInteractInstantProp 외 획득 source가 필요할 때; stock과 acquisition event를 합치지 않음',
        ['현재 typed acquisition program','정확 QuestFakeItemSheet output ID','현재 소유 target/callback와 native event'],targets=['fakeitem']),
      _rule('equipment-future-result','장비 Rarity/Season 미래 산출 조건',
        ['inventory.hold','life.craft'],'masukja2/objective_inventory_policy.py','current_equipment_craft_output_constraints_unbound',
        '현재 HoldEquipment의 Rarity 또는 Season이 지정되고 새로운 제작이 필요한 경우; 이미 보낸 입력은 보존',
        ['현재 native optional constraint','source recipe의 생성 조건 보장 또는 명시적인 비보장 시도 정책','현재 결과 NonStackableId→owned Equipment 판정'],targets=['equipment']),
      _rule('gather-route-domain','직접 채집 및 재료 공급의 미연결 route/domain',
        ['life.gather','inventory.hold','life.craft','life.alter'],'masukja2/life_observer.py','unsupported_objective_type_or_group_domain',
        '현재 GatheringSuccess/GatherableSheetId의 단일 고정 route 외 domain이거나 해당 ID의 route가 없거나 여러 개인 경우. native quest autoplay는 별도 경로이며 채집 adapter 부족과 전체 목표 실패는 다르다.',
        ['정확 current typed objective/ID domain','동일 source revision의 단일 route와 선택/detail 입력','실제 eligibility/activity/native effect']),
      _rule('gather-canvas-profile','16:9 UI70 외 채집 선택 입력 좌표 공급',
        ['life.gather','inventory.hold','life.craft','life.alter'],'masukja2/arbeit_movement.py','egg_selection_profile_not_bound',
        '채집 공급을 선택했지만 화면 비율이 16:9가 아니거나 UI 배율이 70이 아닌 경우. 일반 HUD landscape/portrait 관측 공급과 고정 채집 tape 지원 범위는 다르다.',
        ['해당 current canvas의 source selector/detail layout','기존 selection 순서를 유지하는 실제 좌표 공급','현재 화면/receiver/활동 효과']),
      _rule('item-use-unique-name','UniqueName 전용 품목 사용',
        ['objective.use_quest_item'],'masukja2/story_ui_actions.py','current_fake_item_card_has_no_unique_name_binding',
        '현재 UseQuestFakeItem.UniqueName has_value=True일 때; 정적 설명에서 발생 건수를 추정하지 않음',
        ['현재 UniqueName operand','정확 current instance→card selection/use callback 조인'],runtime_types=['UseQuestFakeItemQuestObjectiveData']),
      _rule('group-npc-zero','구체 NPC=0 그룹의 다중 현재 후보 선택 규칙',
        ['dialogue.talk','objective.deliver'],
        'masukja2/group_npc_movement.py','if len(candidates)!=1:',
        '현재 typed target이 group-only이고 검증된 현재 member가 여러 개인 경우의 선택 규칙. 단일 member 경로는 구현됨; 현재 NPC 부재는 별도 B',
        ['현재 group typed program 및 제약','current spawned group member와 exact callback','회차가 일치하는 native effect']),
      _rule('inventory-context-layout','가방 수평·custom·모호 context와 추가 분류 callback',
        ['objective.use_quest_item'],'masukja2/quest_item_inventory_navigation.py','current inventory target requires a different layout axis or render update',
        '정확 item 모델이 지원 밖 축/분류/receiver에 있거나 기본 필터에서도 보이지 않는 경우',
        ['현재 inventory context/model/filter','등록된 category·scroll callback와 native 축','변경 후 exact row 렌더 및 input receiver'],runtime_types=['UseQuestFakeItemQuestObjectiveData']),
      _rule('inventory-dynamic-subcategory','동적 분류 dropdown·submenu·미렌더 default 제어',
        ['objective.use_quest_item'],'masukja2/quest_item_inventory_navigation.py','current default dynamic category opens a submenu',
        '기본 All의 exact rendered Toggle reset은 구현되어 있다. active dropdown, default submenu, default control 미렌더 또는 추가 callback이 필요한 경우는 별도 source 경로가 필요하다.',
        ['현재 dropdown/submenu/model 소유와 source callback','미렌더 default control의 실제 layout/receiver','각 UI 전이의 후행 current state']),
      _rule('map-scroll-layout','수락 지도 카드의 복합축·parent 위임·anchor 0·미해석 rule',
        ['move.target','dialogue.accept'],'masukja2/collection_scroll_axis.py','current collection compound axis needs its own source route',
        '수락 Collection 카드에서 현재 선택되지 않은 compound축으로 전환, overlap parent 위임, visible anchor 0, 미해석 rule/converter 또는 비축정렬 회전·음수 scale이 필요한 경우. 일반 objective.move 지역 이동과는 다른 경로다.',
        ['현재 compound/parent scroll receiver와 source rule','현재 rooted Canvas 및 exact unrendered layout','회전 geometry/receiver의 source 공급']),
      _rule('recipe-scroll-layout','생활 레시피 복합축·parent 위임·미해석 투영',
        ['life.craft','life.alter','inventory.hold'],'masukja2/map_quest_accept_observer.py','current collection layout rule implementation unsupported',
        '복합축/overlap parent/anchor 0/미해석 rule·converter 또는 비축정렬 회전이 필요한 경우. map-card의 새 수평 source 회귀를 recipe 전체 통합 검증으로 확대하지 않는다.',
        ['현재 recipe model/order와 compound/parent receiver','미해석 source converter 또는 rooted Canvas','현재 scroll 변화와 exact recipe 행']),
      _rule('world-interaction-family','InstantProp 밖 상호작용 계열',
        ['objective.inspect','objective.unresolved'],'masukja2/world_target_observer.py','owned_instant_prop_program',
        '현재 typed interaction이 지원된 InstantProp source 또는 native autoplay로 수행되지 않는 경우',
        ['현재 typed 목표 타입/target operand','exact current world instance generation','HUD callback 및 native effect'],kinds=['inspect_or_interact','unclassified']),
      _rule('unsupported-current-objective','현재 지원 밖 typed 목표/콘텐츠',
        sorted(set(HINT_OPERATIONS.values()))+list(RUNTIME_PROFILES),'masukja2/typed_objective_operands.py','normalize_objective_operands',
        '현재 typed program/operand가 normalizer와 source executor의 지원 밖인 경우. 43개 unclassified만의 문제가 아니며 497개 모두의 현재 분기는 정적 자료로 확정 불가',
        ['현재 owned typed Data/AutoPlay','정확 source input/receiver','같은 발생회차의 native 목표 효과'],all_objectives=True),
      _rule('dungeon-custom-entry','native 목표 autoplay 밖 던전 입장/콘텐츠 행동',
        ['objective.dungeon_clear'],'masukja2/quest_autoplay.py','def plan_quest_autoplay(',
        '현재 native source callback이 수행할 수 없는 입장·콘텐츠 조작이 필요한 경우',
        ['현재 typed dungeon target/admission','정확 source 입장 조건/입력','native clear effect'],kinds=['dungeon_clear']),
    ]



def resolved_rules():
    rows=[
      _rule('source-story-native-handoff','현재 native 요청→rooted Story 페이지 인계',
        ['objective.read_story'],'masukja2/story_native_handoff.py','def current_story_adoption(',
        '정확 current native target·sent ledger·ReadStoryBox·rooted server/UI Pages가 일치하는 지원 경계',
        ['실게임 현재 source/효과는 B로 남음']),
      _rule('source-inventory-category-filter-wheel','현재 All·filter·등록 wheel 입력 경로',
        ['objective.use_quest_item'],'masukja2/quest_item_inventory_navigation.py','def navigate_inventory(',
        'r6 지원 context/model/축/receiver의 category·filter·wheel 입력 경로; dynamic submenu/UniqueName와 구분',
        ['실게임 현재 receiver/렌더 변화는 B로 남음']),
      _rule('source-group-npc-single','NPC=0의 단일 source-owned 그룹 member 경로',
        ['dialogue.talk','objective.deliver'],'masukja2/group_npc_observer.py','def bind_current_group_npc_targets(',
        'exact current group membership와 단일 owned spawned target에서 이동/대화/전달 인계',
        ['현재 group member/receiver/native 효과는 B로 남음']),
    ]
    rows.extend([
      _rule('source-inventory-dynamic-default','현재 렌더된 동적 기본 All 분류 재선택',
        ['objective.use_quest_item'],'masukja2/quest_item_inventory_navigation.py','def dynamic_category(',
        '정확 current context default All과 registered Toggle의 실제 generic ButtonCollection→Radio→DynamicGroup callback만 source 조인; active dropdown/submenu/미렌더 control은 제외',
        ['tests/test_inventory_navigation_continuation_r7.py의 single-start dynamic default→card→Use→native mock effect']),
      _rule('source-collection-single-axis','Collection 수평·수직 단일축 및 실제 parent 부호',
        ['move.target','dialogue.accept'],'masukja2/collection_scroll_axis.py','def parent_screen_sign(',
        'HorizontalOnly/VerticalOnly와 None0의 현재 선택된 단일 compound, exact adapter와 clean native parent TRS를 소비. 선택되지 않은 축 전환/overlap/anchor0/비축정렬 회전·음수 scale은 별도 A',
        ['tests/test_collection_scroll_axis_r7.py의 source-native layout/receiver 검사']),
      _rule('source-direct-clock-envelope','실제 관측 envelope의 직접 효과 시각 연결',
        ['dialogue.accept','dialogue.report'],'masukja2/direct_dialogue_request.py','reconcile_direct_dialogue_request',
        'r6 실제 observer 시각 키 누락을 보완한 source 전달 계약; 서버 행동 소유 추정은 추가하지 않음',
        ['tests/test_direct_dialogue_observer_clock_r7.py actual observer/worker 회귀']),
      _rule('source-direct-objective-baseline','직접 보고 요청의 원 objective baseline 전달',
        ['dialogue.report'],'masukja2/game_observer.py','objective_evidence',
        '원시각과 occurrence를 보존한 objective evidence deepcopy를 UI frame에 연결. WaitingForAchieve와 native False 공존은 별도 미확정',
        ['tests/test_direct_dialogue_observer_baseline_r7.py actual observer/provider/final guard 회귀']),
    ])
    for row in rows:row['status']='resolved';row['resolution_scope']='source implementation only; live unverified'
    return rows



REQUIREMENT_IDS={
 'material-acquisition':['AR-06','AR-07','AR-09'],
 'fake-acquisition-family':['AR-06','AR-07','AR-09'],
 'equipment-future-result':['AR-06','AR-07'],
 'gather-route-domain':['AR-06','AR-07','AR-08'],
 'gather-canvas-profile':['AR-02','AR-06','AR-07'],
 'item-use-unique-name':['AR-05','AR-06','AR-08'],
 'inventory-context-layout':['AR-05','AR-06','AR-08'],
 'inventory-dynamic-subcategory':['AR-05','AR-06','AR-08'],
 'group-npc-zero':['AR-04','AR-05'],
 'map-scroll-layout':['AR-03','AR-04'],
 'recipe-scroll-layout':['AR-06','AR-07'],
 'world-interaction-family':['AR-08'],
 'unsupported-current-objective':['AR-08'],
 'dungeon-custom-entry':['AR-08'],
 'source-story-native-handoff':['AR-05','AR-08','AR-11'],
 'source-inventory-category-filter-wheel':['AR-05','AR-06','AR-08'],
 'source-inventory-dynamic-default':['AR-05','AR-06','AR-08'],
 'source-group-npc-single':['AR-04','AR-05'],
 'source-collection-single-axis':['AR-03','AR-04'],
 'source-direct-clock-envelope':['AR-03','AR-05','AR-11'],
 'source-direct-objective-baseline':['AR-05','AR-11'],
}

def _requirements_for(rule):
    if 'requirement_ids' in rule:return list(rule['requirement_ids'])
    if rule['id'] in REQUIREMENT_IDS:return list(REQUIREMENT_IDS[rule['id']])
    # The optional dialogue audit supplies its own exact requirement links;
    # its minimal interface can still relate direct input ops conservatively.
    result=set()
    for op in rule.get('affected_ops',[]):
        if op=='dialogue.accept':result.update(('AR-03','AR-05'))
        elif op in ('dialogue.report','dialogue.talk'):result.add('AR-05')
        elif op=='reward.claim':result.add('AR-10')
    return sorted(result)


def _read(path):
    return json.loads((root()/path).read_text(encoding='utf8'))


def _evidence(row):
    path=(root()/row['path']).resolve()
    if not path.is_relative_to(root().resolve()):return dict(row,exists=False,anchor_found=False,outside_workspace=True)
    if not path.is_file():return dict(row,exists=False,anchor_found=False)
    raw=path.read_bytes();anchor=row.get('anchor') or ''
    text=raw.decode('utf8')
    return dict(row,exists=True,sha256=hashlib.sha256(raw).hexdigest(),
        anchor_found=not anchor or anchor in text,
        line=(text[:text.index(anchor)].count('\n')+1 if anchor and anchor in text else None))


def _matched(gap,row):
    if gap.get('all_objectives'):return True
    if gap.get('target_kinds') and not set(gap['target_kinds']) & set(row['target_kinds']):return False
    if gap.get('display_kinds') and row['display_kind'] not in gap['display_kinds']:return False
    if gap.get('original_types') and row['original_type'] not in gap['original_types']:
        # Runtime types in a dialogue audit cannot be assigned to preload rows.
        if not gap.get('affected_ops'):return False
    return row['graph_op'] in gap.get('affected_ops',[]) or bool(gap.get('target_kinds'))


REVIEW_PATH='analysis/reviews/r6-test-1-20261009T122651Z/r6-test-1-review.json'


def _static_target_boundaries(objectives):
    """Run the actual pure binder; unbound source tokens are not failed routes."""
    from .arbeit_movement import bind_movement_goal
    rows=[]
    for objective in objectives:
        for index,target in enumerate(objective['targets']):
            bound=bind_movement_goal(target)
            if not bound.get('applicable'):continue
            classification='static_descriptor_ready_current_B'
            needed=['current exact target/geometry/arrival; descriptor is not an input']
            if not bound['ready']:
                if target['kind']=='npc' and 'fresh current exact NPC marker' in ' '.join(bound['missing']):
                    classification='B_current_marker_can_resolve_existing_code'
                    needed=['fresh exact NPC marker supplies a unique world; current presence is not guaranteed']
                else:
                    classification='read_needed_static_token_cannot_determine_runtime_route'
                    needed=['current owned typed objective and descriptor',
                        'source native autoplay or typed region route applicability before classifying remaining A']
            rows.append(dict(objective_id=objective['id'],quest_id=objective['quest_id'],
                step_id=objective['step_id'],objective_index=objective['objective_index'],target_index=index,
                target=deepcopy(target),ready=bound['ready'],classification=classification,
                missing=deepcopy(bound['missing']),required_evidence=needed,
                source_refs=deepcopy(bound['source_refs']),requirement_ids=['AR-04']))
    return dict(applicable_references=len(rows),static_descriptors_ready=sum(r['ready'] for r in rows),
        unresolved=[r for r in rows if not r['ready']],references=rows,
        static_unresolved_is_automatic_A=False,
        evidence=[_evidence(dict(path='masukja2/arbeit_movement.py',anchor='def _bind_static_movement_goal('))])


def _offline_review(counts,objectives):
    """Historical reviewer limitations and current tests have separate meanings."""
    review=_read(REVIEW_PATH)
    prior=review['independent_counts']
    fields=review['dialogue_action_shape_summary']
    result=dict(source=_evidence(dict(path=REVIEW_PATH,anchor='offline_integration_limits')),
        reviewed_version='r6-test-1',original_limits=deepcopy(review['offline_integration_limits']),
        original_counts=deepcopy(prior),original_action_shape_summary=deepcopy(fields),
        counts_match_current=(prior['objectives']==len(objectives) and prior['operation_counts']==dict(counts)),
        prior_findings_are_current_A=False)
    # These names denote precise offline proof surfaces, not game execution.
    support=[
        dict(id='direct-clock-production-envelope',status='offline_regression_implemented',
            resolves_review_limit=0,scope='Actual GameObserver envelope without top now/captured_at to worker; recording IO',
            evidence=[_evidence(dict(path='tests/test_direct_dialogue_observer_clock_r7.py',anchor='class DirectDialogueObserverClockR7Tests'))]),
        dict(id='inventory-wheel-single-worker',status='offline_regression_implemented',
            resolves_review_limit=1,scope='One Start through actual supplier/consumer/runner; native memory and OS are mocks. Restart is separate.',
            evidence=[_evidence(dict(path='tests/test_inventory_navigation_continuation_r7.py',anchor='def test_single_start_actual_wheel_unchanged_then_exact_render_card_use_and_native_effect'))]),
        dict(id='direct-report-objective-baseline',status='offline_regression_implemented',
            scope='Actual observer-to-UI frame baseline preservation and strict later exact native effect; WaitingForAchieve/False occurrence remains unknown.',
            evidence=[_evidence(dict(path='tests/test_direct_dialogue_observer_baseline_r7.py',anchor='def test_'))]),
    ]
    pending=[
        dict(id='inventory-wheel-worker-restart',category='offline_verification',status='not_demonstrated',
            review_limit_index=1,affected_ops=['objective.use_quest_item'],
            scope='Full wheel/card/Use journey restart with durable pending; one-worker positive test is not restart evidence'),
        dict(id='map-horizontal-full-worker-restart',category='offline_verification',status='not_demonstrated',
            affected_ops=['move.target','dialogue.accept'],
            scope='Source axis/provider/recording input path has bounded tests; full horizontal journey and restart are not demonstrated'),
        dict(id='localized-server-pages-full-worker',category='offline_verification',status='not_demonstrated',
            review_limit_index=2,affected_ops=['objective.read_story'],
            scope='Localized rooted server Pages through full worker and restart; literal pages and isolated provider tests are narrower'),
    ]
    return result,support,pending


def build_gap_matrix(*,catalog=None,graphs=None,dialogue_gaps=None):
    catalog=load_catalog() if catalog is None else catalog
    bundle=compile_all(catalog) if graphs is None else graphs
    nodes=[n for g in bundle['graphs'] for n in g['nodes']]
    by_scope={}
    for n in nodes:
        if n.get('objective_index') is not None:
            key=(n['quest_id'],n['step_id'],n['objective_index'])
            if key in by_scope:raise ValueError('duplicate graph objective scope')
            by_scope[key]=n
    objectives=[];seen=set()
    for q in catalog['quests']:
        for step in q['steps']:
            for index,o in enumerate(step['objectives']):
                scope=(q['id'],step['id'],index)
                if scope in seen:raise ValueError('duplicate source objective scope')
                seen.add(scope)
                if scope not in by_scope:raise ValueError('source objective absent from graph')
                n=by_scope[scope];typed=o.get('typed_descriptor') or {}
                objectives.append(dict(id=n['id'],quest_id=q['id'],quest_name=q['name'],quest_title=q['title'],
                    step_id=step['id'],step_index=step['index'],objective_index=index,
                    original_objective_id=o.get('id'),identity_basis='quest_id/step_id/objective_index; absent source ID is not invented',
                    original_type=typed.get('serialized_type'),runtime_type=typed.get('runtime_action_type'),
                    display_kind=o.get('kind','unclassified'),description=o.get('text'),raw_description=o.get('raw_text'),
                    target_kinds=sorted({t['kind'] for t in o.get('targets',[])}),targets=deepcopy(o.get('targets',[])),
                    source_ref=deepcopy(o.get('source_ref')),graph_node_ids=[n['id']],graph_op=n['op'],
                    source_counter_goal=o.get('goal'),counter_is_item_quantity=False,gap_ids=[],definite_gap_ids=[],conditional_gap_ids=[]))
    if set(by_scope)!=seen:raise ValueError('graph contains non-source objective scope')
    rules=base_rules()+resolved_rules()
    dialogue_path=root()/'data/r7-dialogue-gaps.json'
    dialogue=dialogue_gaps if dialogue_gaps is not None else (_read('data/r7-dialogue-gaps.json') if dialogue_path.exists() else None)
    if dialogue is None:
        rules.append(_rule('dialogue-detail-audit-pending','직접 대화 세부 source 감사 통합 대기',
            ['dialogue.accept','dialogue.report','dialogue.talk'],'masukja2/direct_dialogue_request.py','build_direct_dialogue_request',
            '정적 후보/조건으로 effect owner를 만들지 않음; 동료의 타입·상황별 원문 감사가 아직 제공되지 않음',
            ['data/r7-dialogue-gaps.json의 실제 타입·상황별 증거']))
    else:
        if not isinstance(dialogue.get('gaps'),list):raise ValueError('dialogue gaps list required')
        rules.extend(deepcopy(dialogue['gaps']))
    gaps=[];history=[]
    for rule in rules:
        if not rule.get('id') or rule.get('impact') not in ('conditional','definite'):raise ValueError('gap identity and impact required')
        matches=[r for r in objectives if _matched(rule,r)]
        # Explicit runtime-only operations have unknown static occurrence count.
        operator_nodes=[n['id'] for n in nodes if n['op'] in rule.get('affected_ops',[])]
        op_nodes=sorted({r['id'] for r in matches} | {n['id'] for n in nodes if n.get('objective_index') is None and n['op'] in rule.get('affected_ops',[])})
        item=dict(rule,requirement_ids=_requirements_for(rule),evidence=[_evidence(e) for e in rule.get('evidence',[])],
            objective_ids=[r['id'] for r in matches],quest_ids=sorted({r['quest_id'] for r in matches} | {n['quest_id'] for n in nodes if n['id'] in op_nodes}),
            graph_node_ids=op_nodes,operator_surface_node_ids=operator_nodes,static_related_objectives=len(matches),
            exact_static_reference_objective_ids=[r['id'] for r in matches] if rule.get('target_kinds') else [],
            definite_objective_ids=[r['id'] for r in matches] if rule['impact']=='definite' else [],
            conditional_objective_ids=[r['id'] for r in matches] if rule['impact']=='conditional' else [],
            runtime_ops=[op for op in rule.get('affected_ops',[]) if op in RUNTIME_PROFILES],
            live_blocked_count=None,live_effect_verified=False)
        if item.get('status')=='resolved':history.append(item);continue
        gaps.append(item)
        for r in matches:
            r['gap_ids'].append(item['id'])
            r[item['impact']+'_gap_ids'].append(item['id'])
    if len({g['id'] for g in gaps+history})!=len(gaps+history):raise ValueError('duplicate gap ID')
    counts=Counter(n['op'] for n in nodes)
    review,support,offline_gaps=_offline_review(counts,objectives)
    operations=[dict(op=op,nodes=counts[op],implementation_profile=deepcopy(PROFILES.get(op)),
        gap_ids=[g['id'] for g in gaps if op in g.get('affected_ops',[])],
        objective_ids=[r['id'] for r in objectives if r['graph_op']==op]) for op in sorted(counts)]
    saved_graphs=_read('data/arbeit-graphs.json')
    artifact_nodes={n['id']:n['op'] for g in saved_graphs['graphs'] for n in g['nodes']}
    compiled_nodes={n['id']:n['op'] for n in nodes}
    saved_coverage=_read('data/arbeit-implementation-coverage.json')
    artifact_counts={r['op']:r['nodes'] for r in saved_coverage['rows']}
    source_comparison=dict(saved_graph_nodes_equal=artifact_nodes==compiled_nodes,
        saved_graph_catalog_fingerprint_equal=saved_graphs.get('source_fingerprint')==bundle.get('source_fingerprint'),
        saved_implementation_counts_equal=artifact_counts==dict(counts),
        source_compiler_is_mapping_authority=True)
    identity_counts={kind:dict(references=sum(sum(t['kind']==kind for t in r['targets']) for r in objectives),
        objectives=sum(kind in r['target_kinds'] for r in objectives),
        quest_ids=sorted({r['quest_id'] for r in objectives if kind in r['target_kinds']})) for kind in ('fakeitem','equipment')}
    requirements=deepcopy(_read('data/arbeit-coverage.json')['requirements'])
    for r in requirements:
        r['legacy_code_gaps_are_current_proof']=False
        r['current_gap_ids']=[g['id'] for g in gaps if r['id'] in g['requirement_ids']]
        r['gap_mapping_basis']='explicit common solution relation; legacy annotations remain historical only'
    return dict(schema='masukja.arbeit-gap-matrix.v1',denominator=dict(definitions=len(catalog['quests']),objectives=len(objectives),
        graph_nodes=len(nodes),static_operations=len(counts),runtime_only_operations=len(RUNTIME_PROFILES),
        basis='all source definitions/objectives, never implemented paths',unknown_server_branches=None),
        objectives=objectives,operations=operations,common_nodes=[dict(id=n['id'],op=n['op'],quest_id=n['quest_id'],step_id=n.get('step_id')) for n in nodes if n.get('objective_index') is None],
        runtime_operations=[dict(op=op,static_objective_count=None,nodes=0,basis='current typed program; not inferred from preload',
            profile=deepcopy(profile),gap_ids=[g['id'] for g in gaps if op in g.get('affected_ops',[])]) for op,profile in RUNTIME_PROFILES.items()],
        gaps=gaps,history=history,requirements=requirements,
        original_type_matrix=[dict(original_type=kind,display_kind=hint,objectives=len(rows),quest_ids=sorted({r['quest_id'] for r in rows}),graph_ops=sorted({r['graph_op'] for r in rows}),gap_ids=sorted({g for r in rows for g in r['gap_ids']}),runtime_type_inferred=False)
            for kind,hint in sorted({(r['original_type'],r['display_kind']) for r in objectives})
            for rows in [[r for r in objectives if (r['original_type'],r['display_kind'])==(kind,hint)]]],
        original_type_counts=dict(Counter(r['original_type'] for r in objectives)),display_kind_counts=dict(Counter(r['display_kind'] for r in objectives)),
        source_comparison=source_comparison,static_identity_counts=identity_counts,
        static_target_boundaries=_static_target_boundaries(objectives),
        independent_review=review,support_evidence=support,offline_verification_gaps=offline_gaps,
        source_boundary=dict(unknown_runtime_types=sum(r['runtime_type'] is None for r in objectives),
            kind='preload source limitation; current typed readers exist; not automatically an A or a B failure'),
        dialogue_audit_integrated=dialogue is not None,dialogue_audit=deepcopy(dialogue),
        static_identity_scope=deepcopy(_read('data/objective-inventory/requirement-scope.json')),
        input_artifacts=[_evidence(dict(path=p,anchor='')) for p in ('data/arbeit-catalog.json','data/arbeit-graphs.json',
            'data/arbeit-coverage.json','data/arbeit-implementation-coverage.json','masukja2/arbeit_graph.py','masukja2/arbeit_coverage.py','masukja2/arbeit_gap_matrix.py')] + ([_evidence(dict(path='data/r7-dialogue-gaps.json',anchor=''))] if dialogue_path.exists() else []),
        live_verified_routes=0,whole_live_routes_ready=0,
        current_state_B=['current server eligibility/occurrence','current UI/geometry/receiver','actual input/native effects and recovery'],
        meaning='Conditional affected references are not observed blocked routes. Source tokens are not runtime types or quantities.')


def load_gap_matrix():
    return _read('data/arbeit-gap-matrix.json')


def filter_quest(matrix,quest_id):
    result=deepcopy(matrix);rows=[r for r in result['objectives'] if r['quest_id']==quest_id]
    if not rows:raise ValueError('quest ID absent from source denominator')
    ids={r['id'] for r in rows};result['objectives']=rows
    result['common_nodes']=[n for n in result['common_nodes'] if n['quest_id']==quest_id]
    result['gaps']=[g for g in result['gaps'] if ids & set(g['objective_ids']) or any(n.startswith('q'+str(quest_id)+'/') for n in g['graph_node_ids'])]
    result['view']=dict(filtered_quest_id=quest_id,visible_objectives=len(rows),global_denominator_preserved=True)
    return result


def render_markdown(matrix):
    d=matrix['denominator']
    lines=['# r7 전체 아르바이트 A 목표 대응표','',
        f"원본 정의 {d['definitions']}개, 목표 {d['objectives']}개, 공통 {d['static_operations']} 연산·{d['graph_nodes']} 노드를 모두 보존한다. runtime 품목 사용은 별도이며 정적 발생 건수는 미확정이다.",'',
        '497 preload 목표의 원본 serialized type과 display 분류를 현재 typed action과 구분한다. `original_objective_id=null`은 원문에 ID가 없다는 뜻이며 복합 quest/step/index로 추적한다. 모든 목표/원문/노드 관계는 data/arbeit-gap-matrix.json의 objectives에 있다. 조건부 영향 건수는 현재 실패 경로 수가 아니다.','','| A | 상태 | 관련 정적 목표 | 확실 영향 | 조건부 영향 |','|---|---|---:|---:|---:|']
    for g in matrix['gaps']:lines.append(f"| {g['id']} — {g['title']} | {g.get('status','open')} | {g['static_related_objectives']} | {len(g['definite_objective_ids'])} | {len(g['conditional_objective_ids'])} |")
    lines+=['','| 원본 설명 분류 (현재 타입 아님) | 목표 수 |','|---|---:|']
    lines += [f"| {kind} | {count} |" for kind,count in sorted(matrix['display_kind_counts'].items())]
    lines+=['','해결된 source 경계 history: '+', '.join(g['id'] for g in matrix['history'])+'.','','각 gap의 조건, 정확한 source 파일/행/해시, 필요한 읽기 증거, 공통 해결 위치는 JSON에 보존한다. 범위가 겹치므로 행별 건수를 더하지 않는다. 정적 토큰으로 확실히 관련된 대상도 현재 실행 조건이 확인되지 않으면 확실히 막혔다고 표시하지 않는다.','',
        '요구 기반 coverage의 기존 code_gaps는 원문 보존용이다. 새 source 코드의 해결 여부를 대신하지 않는다. resolved 기록은 history에 분리한다. 동료 대화 감사 통합: '+str(matrix['dialogue_audit_integrated'])+'.','',
        '현재 게임 관측 B와 서버 내부의 미확인 인과 규칙은 A 해결 수로 합산하지 않는다. 실제 게임 읽기·입력·캡처·CLI 0, live 성공/복구 0. 전체 완료를 의미하지 않는다.','',
        '재생성: `python -B tools/report_arbeit_gaps.py --write`; 읽기: `python -B tools/report_arbeit_gaps.py --quest-id <ID>`.']
    boundaries=matrix['static_target_boundaries']
    lines += ['', '정적 이동 descriptor 재계산: '+str(boundaries['static_descriptors_ready'])+'/'+str(boundaries['applicable_references'])+'. 미연결 4참조는 자동 A로 집계하지 않는다. 각 원본 목표, exact token, 현재 NPC marker로 해결 가능한 B와 typed objective 읽기가 필요한 경계는 static_target_boundaries에 보존한다.', '', '| 오프라인 검증 항목 | 현재 범위 |', '|---|---|']
    lines += [f"| {row['id']} | {row['scope']} |" for row in matrix['support_evidence']]
    lines += [f"| {row['id']} — 미검증 | {row['scope']} |" for row in matrix['offline_verification_gaps']]
    lines += ['', '오프라인 통합 검증 공백은 source A 또는 실게임 B와 별도다. r6 리뷰 원문과 현재 조치·시험 근거를 각각 보존하며, 같은 항목의 단위 시험이나 단일 worker 성공을 restart 또는 실게임 성공으로 넓히지 않는다.']
    return '\n'.join(lines)+'\n'
