diff --git a/README.md b/README.md index b45bf54..f52a5be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ +현재 구현: [수치별 역할 연결 순수 계약](docs/changes/role-link-contract-implementation.md). 원문 후보·수치별 전체 관계·불확실/미검토·정확 인용 위치를 보존하고 원문에서 다시 검증하는 두 모듈을 구현했다. 신규201개와 인접523개 검사가 통과했다. 독립 검토 C0/I0/M0·새 전체8,300개 QA와 린트/형식/타입/빌드/OpenAPI 검증을 통과했으며 모델 SDK/실제 관계 정확도·앱 적용은 후속이다. + # IssueDigest -다음 설계: [수치별 역할 적용 관계·명시 coverage](docs/changes/role-link-coverage.md). 후보×수치의 모든 관계에 복수 역할·관련 없음·불확실·미검토를 기록하는 행렬을 설계했다. 원문/proposal과 실제 잔여 예산을 유지한 무호출 시제품이며 새SDK/실제 관계 정확도는 미검증이다. +기준 설계: [수치별 역할 적용 관계·명시 coverage](docs/changes/role-link-coverage.md). 후보×수치의 모든 관계에 복수 역할·관련 없음·불확실·미검토를 기록하는 행렬을 설계했다. 원문/proposal과 실제 잔여 예산을 유지한 무호출 시제품이며 새SDK/실제 관계 정확도는 미검증이다. 최신 실제 평가: [table 검수5회](docs/research/role-table-acceptance.md). 긴 자료도 전체 원문/추출을 유지해 검수를 완료했지만, 사전 역할 오류8대상 중 탐지2/미탐6이며 추가진단3개는 판정 유보다. 참고$0.0355525·원래 결과/비용을 보존하고 앱 연결을 보류한다. diff --git a/docs/README.md b/docs/README.md index 1f3cd3f..960a7af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,8 @@ +현재 구현: [수치별 역할 연결 순수 계약](changes/role-link-contract-implementation.md). 원문 후보·수치별 전체 관계·불확실/미검토·정확 인용 위치를 보존하고 원문에서 다시 검증하는 두 모듈을 구현했다. 신규201개와 인접523개 검사가 통과했다. 독립 검토 C0/I0/M0·새 전체8,300개 QA와 린트/형식/타입/빌드/OpenAPI 검증을 통과했으며 모델 SDK/실제 관계 정확도·앱 적용은 후속이다. + # 문서 안내·협업 방법 -다음 설계: [수치별 역할 적용 관계·명시 coverage](changes/role-link-coverage.md). 후보×수치의 모든 관계에 복수 역할·관련 없음·불확실·미검토를 기록하는 행렬을 설계했다. 원문/proposal과 실제 잔여 예산을 유지한 무호출 시제품이며 새SDK/실제 관계 정확도는 미검증이다. +기준 설계: [수치별 역할 적용 관계·명시 coverage](changes/role-link-coverage.md). 후보×수치의 모든 관계에 복수 역할·관련 없음·불확실·미검토를 기록하는 행렬을 설계했다. 원문/proposal과 실제 잔여 예산을 유지한 무호출 시제품이며 새SDK/실제 관계 정확도는 미검증이다. 최신 실제 평가: [table 검수5회](research/role-table-acceptance.md). 긴 자료도 전체 원문/추출을 유지해 검수를 완료했지만, 사전 역할 오류8대상 중 탐지2/미탐6이며 추가진단3개는 판정 유보다. 참고$0.0355525·원래 결과/비용을 보존하고 앱 연결을 보류한다. diff --git a/docs/changes/role-link-contract-implementation.md b/docs/changes/role-link-contract-implementation.md new file mode 100644 index 0000000..8778862 --- /dev/null +++ b/docs/changes/role-link-contract-implementation.md @@ -0,0 +1,56 @@ +# Role-link pure contract implementation + +2026-10-22 KST. Binding spec: [reviewed design](role-link-coverage.md). Existing isolated worktree verified clean; OpenSpec root:null, no initialization. Two sequential tasks implement only pure contracts, not the borrowed-model SDK. + +## Global Constraints + +Preserve all450 existing non-Markdown tracked files,562 actual-trial evidence files,45 earlier design and59 reviewed role-link design files. Only two new production modules and two matching synthetic test modules. No providers/network/old runners/private actual evidence in workers, no SDK/Model calls, app/API/UI/DB/worker/default-model changes, deployment/main merge. No fake legacy audit/report receipts, hard omission/repair, full semantic/source coverage or human approval. Existing exact RoleAliasGenerationV2 and full original table packet stay unchanged. Use pure existing _guard/_original_values/_checked_generation/locator helpers read-only, validate required markers and exact raw/actual/nested/container/tuple/scalar types before dumps. No missing defaults or normalization. TDD, explicit restore_introduced_modules fixture from test_shared_measurement_context_v2, focused and meaningful adjacent/static checks only. Controller owns full QA/preservation/privacy/offline actual-source checks/reviews/PR/Sites. Workers must not spawn subagents or push/deploy. Exact public signatures/types below are the Task1→Task2 interface; report any unavoidable naming clarification. + +## Task 1: Deterministic surface catalog and domains + +- [x] Implement and test pure catalog. + +Own only packages/agents/src/issuedigest_agents/role_link_catalog.py and packages/agents/tests/test_role_link_catalog.py plus own checkbox. Read binding spec. APIs build_role_link_catalog(source:Source,generation:RoleAliasGenerationV2)->RoleLinkCatalog; validate_role_link_catalog(source:Source,result:RoleLinkCatalog|dict[str,Any])->RoleLinkCatalog; role_link_packet(source:Source,generation:RoleAliasGenerationV2)->dict[str,Any]. Export ROLE_ORDER='STOREIBP', immutable stable role metadata (or fresh role_legend projection) mapping S measurement_scope,T measurement_period,O operator,R reporter,E entity_qualifier,I programme_identity,B comparison_baseline,P purpose; states '-' explicitly_unrelated,'?' linkage_uncertain,'_' not_reviewed. Object key order is NOT role order. Provide pure encoding metadata/version for Task2 to hash. Required literal markers/no defaults: catalog profile='role-link-catalog-v1', rules_version='surface-role-hints-v1', source_inventory_exhaustive=False. Catalog includes exact original generation; generation_sha256 hash of canonical original; source_packet_sha256 hash of complete existing build_role_table_packet; rules_sha256 hash of exact patterns/limits/context rules; hints tuple[RoleLinkHint,...]; domains tuple[RoleLinkDomain,...]. Hint fields hint_id,quote_id,anchor:RoleAliasAnchor,span:ExactSpan,origins tuple of exact origin labels. Domain fields quote_id,numbers tuple[str,...],hints tuple[str,...]. Source quote/table and current selection validated before any serialization; validator extracts/statically guards original generation before dumping then reconstructs entire catalog/hash/metadata/domain. Exact model subclasses, unchecked copies, missing nested/default markers, scalar/container subclasses and raw tuple lists rejected. + +Deterministic origin patterns copied verbatim (lexical hints only): +- dot_list: r'[가-힣A-Za-z0-9]+(?:·[가-힣A-Za-z0-9]+)+'; every r'[^·]+' member produces dot_member. +- year_surface: r'[0-9]{1,4}년(?:간|까지|부터)?' +- relative_time_surface: r'(?:올해(?:\s*[0-9]+월(?:부터|까지)?)?|전년도(?:\s*대비)?|현재)' +- institution_suffix_surface: r'[가-힣]+(?:부|청|공단|위원회|정부|대학)(?=[은는이가을를의에과와]|으로|에서|로|[^가-힣]|$)' +- place_suffix_surface: r'[가-힣]+(?:특별자치도|특별시|광역시|도|시|군|구)(?=[은는이가을를의에과와]|으로|에서|로|[^가-힣]|$)' +- quoted_phrase: r"'[^'\n]+'|‘[^’\n]+’|「[^」\n]+」" + +Use current source sentence order, then start/end order; merge only same quote/sentence/start/end origins (sorted exact labels). IDs m1.. global, quote IDs q1.. original quote order. Preserve duplicates at different locations and group/member overlap. For t unique under overlapping occurrence count c=null; otherwise choose shortest context by increasing extra length, then increasing left position, containing exact t exactly once and occurring exactly once in sentence, with overlapping occurrences counted. Must resolve through existing strict locator and verify exact original position; max t/c1000, no normalization. If no context/locator impossible raise sanitized RoleLinkCatalogError(reason='unlocatable_hint'); >24 hints in ANY quote => reason='candidate_overflow', no partial/truncation. Other malformed inputs reason='invalid_input'. Error exposes only reason/sanitized message, no source. Full source remains present even if pattern finds no hint. Runtime ceiling applies to same-position deduplicated hint count, before attempting to repair/omit any hint; report no partial catalog on failure. + +Domains include EVERY quote, all its hints and original binding-order selected numbers from that quote, even numbers0/hints0. Empty selection remains full catalog/source with empty number domains. role_link_packet exactly profile='role-link-review-v1', source_packet=current unchanged existing table packet, hint_table={columns:['id','quote_id','s','t','c'],rows:[[hint_id,quote_id,s,t,c],...]}, domains list of {quote_id,numbers,hints}, role_legend mapping, required role_order, state_legend mapping, source_inventory_exhaustive=False. Origins and exact original spans remain backend catalog only. No arbitrary runtime supplied catalog/projection accepted as canonical. Tests red/green: literal projection/order, per-pattern/group member/dedup/overlap/repeated exact location,24inclusive/25overflow, unlocatable marker/1000, no-hint/empty selection/multiquote, fresh source/gen/catalog drift, marker/raw/actual/tuple/scalar/container/subclass tamper before dumps. No private actual fixtures. Run focused/static/adjacent then commit owned files/checkbox; detailed TDD/report supplied path. + +## Task 2: Explicit matrix report and reconstruction + +- [x] Implement and test pure relation contract. + +Own only packages/agents/src/issuedigest_agents/role_link_contract.py and packages/agents/tests/test_role_link_contract.py plus own checkbox. Consume Task1 catalog APIs/DTOs/constants without edits. APIs build_role_link_report(source:Source,generation:RoleAliasGenerationV2,data:RoleLinkInput|dict[str,Any])->RoleLinkReport; validate_role_link_report(source:Source,result:RoleLinkReport|dict[str,Any])->RoleLinkReport. RoleLinkInput required rows:tuple[str,...],incomplete:bool, no defaults. Required report markers profile='role-link-report-v1',encoding_version='role-link-matrix-v1',role_order='STOREIBP',source_inventory_exhaustive=False. Report includes current RoleLinkCatalog (and thus unchanged generation), catalog_sha256, domain_sha256, encoding_sha256 (hash exact role/order/state/version), input:RoleLinkInput, decisions tuple[RoleLinkDecision,...], coverage:RoleLinkCoverage, status exact Literal described below. All fields required. No usage/model/prompt receipt yet; those belong to later SDK. + +Rows exactly match ALL hints globally; each row's columns match that hint quote's exact domain numbers. Split semicolons;0column row must exactly ''. Positive cells one or more unique letters in ROLE_ORDER; decode to tuple of semantic role names in that order. Accept multi-role SO, reject OS/duplicates/spaces/lowercase/unknown/separator gaps/mixed state+role. Other cells exactly -,?,_. Do not fill missing cells, sort, normalize, infer negatives, or silently discard rows. '_' requires incomplete=true; '?' is distinct from not_reviewed. incomplete=true even without '_' remains conservatively incomplete. All raw/actual/nested scalar/container guards before any serialization, strict bool vs int, subclasses/unchecked copies and missing original source/gen markers rejected. Error RoleLinkContractError sanitized, no partial/source leakage. + +Each RoleLinkDecision includes hint_id,number,state exact classified_roles/explicitly_unrelated/linkage_uncertain/not_reviewed,roles tuple of semantic names,quantity:ExactSpan,hint:ExactSpan,support:ExactSpan,support_sentence_ids tuple[str,...]. For every SAME-QUOTE pair, reconstruct full original numeric candidate.expression and hint exact location; support is minimal contiguous WHOLE current sentences covering full numeric expression and hint disambiguating context (if nonnull), including intermediate/blank sentences. Recheck both spans/quote/order/full quantity against current source. Retain every pair even uncertain/unrelated/unreviewed; no crossquote pairing or semantic truth claim. Do not cast into old20-sentence/5role/144diagnostic audit report; >20 current support sentences are allowed in this OWN exact report. Max288pairs follows24hints perquote *12total selectednumbers, not new hidden truncation. Coverage fields selected_numbers,total_hints,candidate_pairs,classified_pairs,unrelated_pairs,uncertain_pairs,unreviewed_pairs (strict nonnegative ints); counts exact current decisions. + +Status: selected_numbers0 => not_applicable; selected>0 and pairs0 => candidate_inventory_empty. Both zero-pair cases require incomplete=false and exact complete0column carrier, implying no future model request. With pairs>0: incomplete flag => candidate_review_incomplete; else any? => candidate_review_uncertain; else reviewed_candidates. Never no_issues_flagged/approval/exhaustive. Whole quote with numbers0 must not erase other valid pairs. Store source/generation/catalog/domain/encoding hashes via catalog/report and compare every field to fresh canonical reconstruction. Validators must reject raw or actual altered decisions/coverage/roles/spans/input/status/hashes/markers before defaults/normalization; reconstruct whole original catalog from current source, not trusting stored metadata. + +TDD synthetic tests: positive multirole/each state and conservative priority, unreviewed+false reject, explicit ordering regression, full row/column coverage/0columns, empty selection/no inventory/multiquote, no inference on ignored hints, exact full quantity/support/context/blank sentences including >20sentence own report;288pair boundary (no 144legacycap), roundtrip raw/actual result, every marker/metadata/source/gen/catalog/domain/encoding/input/decision/count/spans tamper, wrongtypes/subclasses/default restoration. Structural tests do not claim model classification accuracy. Reuse meaningful fixtures restoring module identity; no private actual gold/source reads. Focused/static/adjacent only. Commit/report as Task1. + +## Controller gates + +- [x] Task/whole-branch independent review and fresh full QA. +- [x] Fresh real-source catalogs/domain/projection and matrix roundtrips offline; preserve450code/562actual/45+59design and privacy. +- [x] Canonical docs, Draft PR and Sites with prior version preserved; archive/remove only own SDD and owned QA resources. No app/main deployment. + + +## Implementation evidence and remaining gate + +Commits24a606a (catalog) and24874f8 (report), two new modules/two synthetic test modules; existing450 non-Markdown files unchanged. New201 tests, adjacent523; task and fresh whole-branch reviews C0/I0/M0. Fresh full QA code24874f8: Python7,480+JS820=8,300; lint/format/type/build/OpenAPI pass, failure/skip/warning0. No source/runtime/app/provider changes after QA; later commits are documentation only. + +Offline current real inputs: all5 catalogs/47 exact hints and unchanged complete table packets match reviewed design; pairs15/16/6/192/42. Four synthetic assignments (?/_/-/SO) per source produce20 full report roundtrips, exact quantity/hint/support, all pairs and expected states. These are structural consistency checks, not model classification accuracy, candidate recall, importance, human approval or proof of exhaustive source coverage. Raw and actual reconstructions both pass. Existing562 actual-trial and45+59 design files unchanged; private60value scan and local document links pass. No actual provider requests, old trial reruns, SDK integration or app/main deployment. + +Next: separately bounded model SDK using this explicit contract; fixed actual classifications/output completeness/candidate recall/false positives and original-result materiality, unresolved human adjudication, then app total budget/permission/durable recovery acceptance. Earlier actual2detected/6missed and3unadjudicated diagnostics remain unchanged. Reconstruction hashes ensure current-source consistency; they are not cryptographic proof that a model originally emitted particular input. The frozen upstream generation still cannot represent sources with zero numeric candidates anywhere; this change supports empty selections and numeric-free quotes within otherwise valid sources without broadening it. + +Draft PR88 targets research/role-link-coverage-design; no merge. Sites v110 published2026-10-22 15:18:15 KST from ac72ba624b2d889bcb00d1adc9e8976cccdc9646; priorv109 archived. Native publication succeeded. Text/link/privacy checks:95HTML/2,180local targets/280public files/60private values. Browser QA not rerun for text-only existing layout. Owned QA container removed and loopback port closed, other resources untouched. Own SDD ledger/reviews archived privately; feature branch/worktree retained. diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md index fab8d78..eb05413 100644 --- a/docs/planning/implementation-plan.md +++ b/docs/planning/implementation-plan.md @@ -4,7 +4,7 @@ ## M3 최신 인계 · 2026-10-22 한국 시간 -[table 검수 실제5회](../research/role-table-acceptance.md)의 2탐지/6미탐·추가hard3유보를 보존하고 [수치별 역할 연결 coverage](../changes/role-link-coverage.md)를 설계했다. 원문/추출을 유지한 명시 행렬로 복수역할/관련없음/불확실/미검토를 구분하는 안이며 현재는 무호출 시제품이다. 후보가 원문 전체를 포괄하거나 원래 추출 누락/중요도를 자동 판정한다고 주장하지 않는다. 다음은 순수 catalog/domain/report 재구성·변조·coverage/locate/overflow 계약의 TDD 구현과 별도 bounded SDK 판단이다. 실제 부호/관계 정확도·출력 완료/후보 recall·G사람 재판정·앱 전체 예산/권한/내구 인수 전 앱 연결은 보류한다. +[table 검수 실제5회](../research/role-table-acceptance.md)의 2탐지/6미탐·추가hard3유보를 보존하고 [수치별 역할 연결 coverage](../changes/role-link-coverage.md)의 [순수 계약](../changes/role-link-contract-implementation.md)을 구현했다. 원문/추출을 유지하며 결정적 후보와 수치별 모든 관계를 기록하고, 복수역할/관련없음/불확실/미검토·정확 원문 위치를 다시 검증한다. 신규201/인접523검사와 실제5자료의 합성 행렬20개 오프라인 검증을 통과했으며 독립 검토 C0/I0/M0·새 전체8,300개 QA(Python7,480+JS820)와 린트/형식/타입/빌드/OpenAPI 검증을 통과했다. 후보가 원문 전체를 포괄하거나 원래 추출 누락/중요도를 자동 판정한다고 주장하지 않는다. 다음은 별도 bounded SDK 판단과 실제 관계 정확도·출력 완료/후보 recall·G사람 재판정·앱 전체 예산/권한/내구 인수다. 앱 연결은 보류한다. ## 제품 여정 @@ -60,7 +60,7 @@ SDK 단독 상한은 요청1회·입력+출력12,000/출력500/15초다. 실제 | --- | --- | --- | --- | | M1 · 질문에서 브리핑까지 | 로그인·권한·저장 검색·AI 생성·정확 인용·저장/재열람·실패/취소/복구 연결 | 기반 완료 · 개인 개발 적용 | 개발 웹/API/워커ab6654e/v9·전체4,655개/독립 검토·실제 HTTPS/보존 복원. 의미 품질 부분 충족이며 운영 출시와 구분 | | M2 · 자료 확보·갱신 | 승인 출처를 반복 확보하고 변경/중복·실패·수집 범위/최신성을 추적, 과거 근거 보존 | 진행 중 | 시간당 고정 기사 확보·명시 승인 편입과 날짜/발행처 후보 비교를 구현. 새 공식 텍스트1건 추가·전체3,603개·독립 검토·검색/재실행·사후23테이블 일치 복원 확인. 다매체 확대·최근 주목도·자동 승인/게시 완료는 아님 | -| M3 · 근거 중심 자율 조사 | 질문별 승인 도구 탐색·비교·자료 부족 판단과 검증 모듈 전달, 비용/취소/복구 | 진행 중 · compact/역할 검수 SDK | 실제 v2 의미1/3·사전 차단1 보존. 원문 중복 축소/별도 역할 검수·합산 한도 SDK·합성/오프라인 용량 확인, 독립 검토/전체7,366QA 완료·실제7회 생성0/2/검수 오탐·출력 거부, 앱 보류 | +| M3 · 근거 중심 자율 조사 | 질문별 승인 도구 탐색·비교·자료 부족 판단과 검증 모듈 전달, 비용/취소/복구 | 진행 중 · 역할 연결 순수 계약 | 후보·수치별 전체 관계/정확 원문 재검증 구현·신규201/전체8,300QA·독립 검토 완료. 기존 실제2탐지/6미탐·추가3유보 유지. 별도 bounded SDK·실제 관계 품질/후보 recall·앱 예산/복구 인수 후 연결 | | M4 · 관심 이슈·변화·공유 | 개인 소유·관심 해제/삭제, 새 사실·공식 결정·정정과 알림함, 동의한 불변 공유본 | 예정 | 소유/접근 모델·변경 근거부터 구현. 현재 검증용 공유 이력을 개인 보관함으로 간주하지 않음 | | M5 · 이슈 발견·개인화 | 실제 매체 관측/중복 제거/선정 이유와 동의한 관심 신호의 혼합 | 예정 | 출처/기간/갱신 데이터와 신호 정책·다양성 평가. 임의 인기·여론 점수를 만들지 않음 | | M6 · 공개 운영 | 실제 질문 품질·지연/비용·권한·백업 복원·공개 이용 정책 충족 후 출시 판단 | 예정 · 운영 게시 유예 | 개발 HTTPS·앱 백업 복원 기반은 있음. 전체 출시 조건과 D013 별도 승인 필요 | diff --git a/docs/planning/progress.md b/docs/planning/progress.md index 9217217..0847ea9 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -1,3 +1,21 @@ +## 최종 인계 · 순수 역할 연결 계약 · 2026-10-22 한국 시간 + +[Draft PR88](https://github.com/DigestWorks/IssueDigest/pull/88) · Sites v110 게시 성공(15:18:15 KST, source ac72ba624b2d889bcb00d1adc9e8976cccdc9646), 이전v109 보존. 본문·변경6문서 요약은 게시 시94d6414 기준이다. 이후 이 영수증/완료 체크만 갱신하며 별도 재게시하지 않는다. HTML95/로컬대상2,180/공개텍스트280/실제private60항목 검사 통과·텍스트만 변경해 브라우저QA는 반복하지 않았다. + +자체 QA DB1개 제거·포트 닫힘 확인, 다른 리소스 변경 없음. 자체 SDD 기록·태스크/전체 검토를 비공개 기록으로 보존하고 해당 임시 디렉터리만 정리했다. 브랜치/작업폴더 유지·main 병합/앱 배포/추가provider0. 별도 bounded 모델 SDK·실제 관계 품질 인수가 다음 단계다. + +## 게시 준비 · 순수 역할 연결 계약 · 2026-10-22 한국 시간 + +[Draft PR88](https://github.com/DigestWorks/IssueDigest/pull/88)은 설계 PR87 브랜치를 대상으로 한다. 검증 완료 코드와 문서를 공유하며 병합·앱 배포는 하지 않았다. Sites에는 이번 상태와 이전v109를 보존해 게시한다. + +## 순수 역할 연결 계약 · 검증 완료 · 2026-10-22 한국 시간 + +[두 단계 구현·남은 인수](../changes/role-link-contract-implementation.md). 별도 catalog/domain과 명시 행렬 report를 구현했다. 수치별 모든 후보 관계·복수 역할/관련없음/불확실/미검토·정확 전체 수치와 최소 연속 whole-sentence 근거·현재 원문 재구성을 보존한다. 24후보/288쌍·20문장 초과 근거·엄격한 원본 타입/필수값/변조 거부를 포함한다. 기존 generation·앱/SDK/DB/UI/워커는 변경하지 않았다. + +신규201개(후보59+관계142)·인접523개와 태스크/전체 독립 검토 C0/I0/M0·새 전체8,300QA(Python7,480+JS820)·린트/형식/타입/빌드/OpenAPI 검증 통과. 실패/skip/warning0. QA 코드 기준24874f8 이후 문서만 변경했다. 실제5자료의47후보/전체 packet 일치와20개 합성 관계 입력의 원문·raw/actual 재구성을 오프라인 확인했다. 합성 분류이므로 실제 AI 의미 정확도 증거가 아니다. + +450기존코드/562실제/45+59설계 증거 불변·공개자료 private60항목 검사 통과. 추가 provider0·기존 실제 평가2탐지/6미탐·추가3유보 유지. 다음은 별도 bounded 모델 SDK와 고정 실제 관계 평가·후보 recall/중요도·앱 전체 예산/권한/복구 인수다. main/앱 배포는 보류한다. Sites·PR 인계는 후속 영수증에 기록한다. + ## 최종 영수증 · 역할 연결 coverage 설계 · 2026-10-22 한국 시간 [Draft PR87](https://github.com/DigestWorks/IssueDigest/pull/87)은 PR86 기반·미병합/앱 첨부다. [계획판 v109](https://lawdigest-rebuild-plan.ijaesinii.chatgpt.site/) public/native succeeded, source `db54dd6578a5b52fed455d038fea0362c16f171c`, saved `appgprj_6ac612dd42708191a7a0c8377be64d48~appgver_917ff973b1508191b5f7db5518a12000`, deployment `appgdep_6ad9a0bed7c88191b0842e38847ff56e`, 2026-10-22 14:36:13 KST다. 저장소 사본2fc85e0bc8791cdb5288abf5059d1661d68e5748·이전v108/전체 기록을 보존했다. 변경Markdown6개 중 문서5개만 canonical/flat 사본에 반영하고 루트README는 snapshot 요약으로 표시했다. HTML94개·로컬링크/asset/anchor2,136개·공개텍스트277개·비공개 실제ID/질문/긴인용60종 부재 검사를 통과했다. 기존 레이아웃 텍스트 갱신으로 새 브라우저 검사는 실행하지 않았고 사용자 탭을 열거나 이동하지 않았다. diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index e080b5f..23d7563 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -1,3 +1,13 @@ +## 현재 · 순수 역할 연결 계약 · 2026-10-22 한국 시간 + +[구현 계획·검증 범위](../changes/role-link-contract-implementation.md). + +- [x] 결정적 원문 후보·수치별 domain·원문/추출/규칙 해시와 strict 위치 검증. +- [x] 명시 행렬·복수 역할/관련없음/불확실/미검토·288쌍과20문장 초과 근거·전체 재구성 계약. 신규201/인접523검사 통과. +- [x] 독립 태스크/전체 검토 C0/I0/M0·새 전체8,300QA·450기존코드/562실제/45+59설계 불변·개인정보 검사. +- [x] 문서·[Draft PR88](https://github.com/DigestWorks/IssueDigest/pull/88)·Sites v110/이전v109 인계·자체 SDD 기록/검증 DB 정리. +- [ ] 별도 bounded 모델 SDK와 사전 고정 실제 분류·후보 recall/의미 품질·앱 전체 예산/권한/복구 인수. 앱 연결은 보류. + ## 현재 · 역할 연결 coverage 설계 · 2026-10-22 한국 시간 [후보/행렬·용량·남은 경계](../changes/role-link-coverage.md). @@ -6,7 +16,7 @@ - [x] 기존 후보47개의 strict 위치·현재5자료의 전체 원문/proposal 확인, 최종5/이전 두 초안10 mock HTTP·잘못된 출력90개 거부. 실제 provider0·새SDK0. - [x] 독립 설계 검토의 순서 I1 수정·재검토 C0/I0/M0·450코드/562실제/45기존설계/59신규 설계 증거 불변. - [x] Draft PR87·Sites v109/이전v108·최종 인계 영수증. -- [ ] 별도 순수 catalog/domain/role-link report·전체 재구성/변조/명시coverage/locate/overflow의 TDD 구현 후 bounded SDK 판단. +- [x] 별도 순수 catalog/domain/role-link report의 TDD 구현. 독립 검토·전체QA 후 bounded SDK 판단. - [ ] 사전 고정 실제 관계 분류·복수 역할/관련없음 오탐·출력 완료/미검토·미관측 후보 recall/비용 인수. - [ ] 원래 추출과의 의미 비교/중요도·G사람 재판정·앱 전체 예산/권한/내구 인수. 자동 hard누락/수리·앱 연결은 후속. diff --git a/packages/agents/src/issuedigest_agents/role_link_catalog.py b/packages/agents/src/issuedigest_agents/role_link_catalog.py new file mode 100644 index 0000000..c6c0f47 --- /dev/null +++ b/packages/agents/src/issuedigest_agents/role_link_catalog.py @@ -0,0 +1,337 @@ +"""Pure deterministic surface hints and explicit domains; never semantic coverage.""" + +import re +from hashlib import sha256 +from types import MappingProxyType, UnionType +from typing import Annotated, Any, Literal, TypeAliasType, Union, cast, get_args, get_origin + +from issuedigest_domain.sources import Sha256 +from pydantic import BaseModel + +from .balanced_role_generation import RoleAliasGenerationV2, validate_balanced_role_generation +from .contextual_anchor import ContextualAnchor +from .numeric_candidates import ExactSpan, SentenceCandidate +from .numeric_evidence import Source, _json, _StrictModel +from .numeric_selection_evaluation import _read +from .role_alias_contract import RoleAliasAnchor, _guard +from .role_table_packet import _copy_json, _original_values, _same_json, build_role_table_packet +from .semantic_role_audit_contract import _checked_generation, _frame +from .shared_measurement_context_v2 import _resolve + +ROLE_ORDER = "STOREIBP" +MATRIX_ENCODING_VERSION = "role-link-matrix-v1" +ROLE_LEGEND = MappingProxyType( + dict( + S="measurement_scope", + T="measurement_period", + O="operator", + R="reporter", + E="entity_qualifier", + I="programme_identity", + B="comparison_baseline", + P="purpose", + ) +) +STATE_LEGEND = MappingProxyType( + { + "-": "explicitly_unrelated", + "?": "linkage_uncertain", + "_": "not_reviewed", + } +) +_PATTERNS = ( + ("dot_list", r"[가-힣A-Za-z0-9]+(?:·[가-힣A-Za-z0-9]+)+"), + ("year_surface", r"[0-9]{1,4}년(?:간|까지|부터)?"), + ("relative_time_surface", r"(?:올해(?:\s*[0-9]+월(?:부터|까지)?)?|전년도(?:\s*대비)?|현재)"), + ( + "institution_suffix_surface", + r"[가-힣]+(?:부|청|공단|위원회|정부|대학)(?=[은는이가을를의에과와]|으로|에서|로|[^가-힣]|$)", + ), # noqa: E501 + ( + "place_suffix_surface", + r"[가-힣]+(?:특별자치도|특별시|광역시|도|시|군|구)(?=[은는이가을를의에과와]|으로|에서|로|[^가-힣]|$)", + ), # noqa: E501 + ("quoted_phrase", r"'[^'\n]+'|‘[^’\n]+’|「[^」\n]+」"), +) +_MEMBER_PATTERN = r"[^·]+" +_MAX_HINTS = 24 +_MAX_ANCHOR = 1000 + +type HintOrigin = Literal[ + "dot_list", + "dot_member", + "year_surface", + "relative_time_surface", + "institution_suffix_surface", + "place_suffix_surface", + "quoted_phrase", +] +type CatalogFailure = Literal["invalid_input", "candidate_overflow", "unlocatable_hint"] + + +class RoleLinkCatalogError(ValueError): + """Sanitized whole-operation failure without a source or partial inventory.""" + + def __init__(self, reason: CatalogFailure = "invalid_input") -> None: + self.reason = reason + super().__init__(f"Role link catalog rejected: {reason}") + + +class RoleLinkHint(_StrictModel): + hint_id: str + quote_id: str + anchor: RoleAliasAnchor + span: ExactSpan + origins: tuple[HintOrigin, ...] + + +class RoleLinkDomain(_StrictModel): + quote_id: str + numbers: tuple[str, ...] + hints: tuple[str, ...] + + +class RoleLinkCatalog(_StrictModel): + profile: Literal["role-link-catalog-v1"] + rules_version: Literal["surface-role-hints-v1"] + source_inventory_exhaustive: Literal[False] + generation: RoleAliasGenerationV2 + generation_sha256: Sha256 + source_packet_sha256: Sha256 + rules_sha256: Sha256 + hints: tuple[RoleLinkHint, ...] + domains: tuple[RoleLinkDomain, ...] + + +def role_link_encoding_metadata() -> dict[str, Any]: + """Return fresh complete matrix encoding metadata, independent of object key order.""" + return dict( + encoding_version=MATRIX_ENCODING_VERSION, + role_order=ROLE_ORDER, + role_legend=dict(ROLE_LEGEND), + state_legend=dict(STATE_LEGEND), + ) + + +def _rules() -> dict[str, Any]: + return dict( + rules_version="surface-role-hints-v1", + patterns=[list(p) for p in _PATTERNS], + dot_member_pattern=_MEMBER_PATTERN, + max_hints_per_quote=_MAX_HINTS, + max_anchor_length=_MAX_ANCHOR, + occurrence_count="overlapping-exact-codepoints", + context="null-if-unique-else-increasing-extra-length-then-left-position;" + "phrase-once-in-context-and-context-once-in-sentence;strict-locator-exact-position", + ordering="current-sentence-order-then-start-end;global-m1;original-quote-order-q1", + dedup="same-quote-sentence-start-end-only;sorted-origins;overflow-before-location", + ) + + +def _hash(value: Any) -> str: + return sha256(_json(value).encode("utf-8")).hexdigest() + + +def _actual(value: Any, annotation: Any) -> None: + _guard(value, annotation) + _original_values(value, annotation) + + +def _required_raw(value: Any, annotation: Any) -> None: + """Require every original JSON field before defaults or serialization can restore it.""" + if isinstance(annotation, TypeAliasType): + _required_raw(value, annotation.__value__) + return + origin, args = get_origin(annotation), get_args(annotation) + if origin is Annotated: + _required_raw(value, args[0]) + elif origin in (Union, UnionType): + for option in args: + try: + _required_raw(value, option) + return + except ValueError: + pass + raise ValueError("No exact raw union branch") + elif isinstance(annotation, type) and issubclass(annotation, BaseModel): + if type(value) is not dict: + raise ValueError("Expected raw model object") + fields = annotation.model_fields + names = {field.alias or name for name, field in fields.items()} + if value.keys() != names: + raise ValueError("Missing or extra raw model fields") + for name, field in fields.items(): + _required_raw(value[field.alias or name], field.annotation) + elif origin is tuple: + if type(value) is not list: + raise ValueError("Expected raw JSON array") + if args[-1] is not Ellipsis and len(value) != len(args): + raise ValueError("Wrong raw tuple length") + for i, child in enumerate(value): + _required_raw(child, args[0] if args[-1] is Ellipsis else args[i]) + elif origin is Literal: + if not any(type(value) is type(option) and value == option for option in args): + raise ValueError("Wrong raw literal") + elif annotation in (str, int, bool, float, type(None)) and type(value) is not annotation: + raise ValueError("Wrong raw scalar") + + +def _once(text: str, phrase: str) -> bool: + start = text.find(phrase) + return start >= 0 and text.find(phrase, start + 1) < 0 + + +def _context(text: str, start: int, end: int) -> str | None: + phrase = text[start:end] + if len(phrase) > _MAX_ANCHOR: + raise RoleLinkCatalogError("unlocatable_hint") + if _once(text, phrase): + return None + for extra in range(1, min(_MAX_ANCHOR, len(text)) - len(phrase) + 1): + length = len(phrase) + extra + for left in range(max(0, start - extra), min(start, len(text) - length) + 1): + context = text[left : left + length] + if _once(context, phrase) and _once(text, context): + return context + raise RoleLinkCatalogError("unlocatable_hint") + + +def _build(source: Source, generation: RoleAliasGenerationV2) -> RoleLinkCatalog: + _actual(source, Source) + _actual(generation, RoleAliasGenerationV2) + generation = _checked_generation(source, generation) + source_packet = build_role_table_packet(source, generation) + index = _frame(generation).candidate_index + quotes = {c.citation_id: f"q{i}" for i, c in enumerate(index.citations, 1)} + counts = dict.fromkeys(quotes, 0) + inventory: list[tuple[SentenceCandidate, int, int, tuple[HintOrigin, ...]]] = [] + for sentence in index.sentences: + positions: dict[tuple[int, int], set[HintOrigin]] = {} + for label, pattern in _PATTERNS: + for match in re.finditer(pattern, sentence.span.text): + positions.setdefault(match.span(), set()).add(cast(HintOrigin, label)) + if label == "dot_list": + for member in re.finditer(_MEMBER_PATTERN, match.group()): + key = (match.start() + member.start(), match.start() + member.end()) + positions.setdefault(key, set()).add("dot_member") + counts[sentence.span.citation_id] += len(positions) + for (start, end), labels in sorted(positions.items()): + inventory.append((sentence, start, end, tuple(sorted(labels)))) + if any(count > _MAX_HINTS for count in counts.values()): + raise RoleLinkCatalogError("candidate_overflow") + hints: list[RoleLinkHint] = [] + for sentence, start, end, origins in inventory: + try: + text = sentence.span.text[start:end] + context = _context(sentence.span.text, start, end) + anchor = RoleAliasAnchor(s=sentence.sentence_id, t=text, c=context) + resolved = _resolve( + index, ContextualAnchor(sentence_id=anchor.s, text=anchor.t, context_text=anchor.c) + ) + span = ExactSpan( + citation_id=sentence.span.citation_id, + text=text, + start=sentence.span.start + start, + end=sentence.span.start + end, + ) + if resolved.span != span: + raise ValueError("Locator changed original position") + hints.append( + RoleLinkHint( + hint_id=f"m{len(hints) + 1}", + quote_id=quotes[span.citation_id], + anchor=anchor, + span=span, + origins=origins, + ) + ) + except Exception: + raise RoleLinkCatalogError("unlocatable_hint") from None + candidates = {c.candidate_id: c for c in index.candidates} + domains = tuple( + RoleLinkDomain( + quote_id=qid, + numbers=tuple( + b.number + for b in generation.frame.input.bindings + if candidates[b.number].expression.citation_id == cid + ), + hints=tuple(h.hint_id for h in hints if h.quote_id == qid), + ) + for cid, qid in quotes.items() + ) + return RoleLinkCatalog( + profile="role-link-catalog-v1", + rules_version="surface-role-hints-v1", + source_inventory_exhaustive=False, + generation=generation, + generation_sha256=_hash(generation.model_dump(mode="json")), + source_packet_sha256=_hash(source_packet), + rules_sha256=_hash(_rules()), + hints=tuple(hints), + domains=domains, + ) + + +def build_role_link_catalog(source: Source, generation: RoleAliasGenerationV2) -> RoleLinkCatalog: + """Rebuild every lexical hint and same-quote domain from complete current source.""" + try: + return _build(source, generation) + except RoleLinkCatalogError: + raise + except Exception: + raise RoleLinkCatalogError() from None + + +def validate_role_link_catalog( + source: Source, + result: RoleLinkCatalog | dict[str, Any], +) -> RoleLinkCatalog: + """Reconstruct the complete catalog after guarding original raw or actual values.""" + try: + _actual(source, Source) + if isinstance(result, BaseModel): + _actual(result, RoleLinkCatalog) + original = result.generation + else: + _copy_json(result) + _required_raw(result, RoleLinkCatalog) + original = result["generation"] + # Inspect/reconstruct the ORIGINAL generation before dumping the catalog. + generation = validate_balanced_role_generation(source, original) + checked = _read(RoleLinkCatalog, result) + expected = _build(source, generation) + _same_json(checked.model_dump(mode="json"), expected.model_dump(mode="json")) + if type(result) is dict: + _same_json(result, expected.model_dump(mode="json")) + return expected + except RoleLinkCatalogError: + raise + except Exception: + raise RoleLinkCatalogError() from None + + +def role_link_packet(source: Source, generation: RoleAliasGenerationV2) -> dict[str, Any]: + """Project only a freshly rebuilt catalog; retain the full existing table packet.""" + try: + catalog = _build(source, generation) + return dict( + profile="role-link-review-v1", + source_packet=build_role_table_packet(source, generation), + hint_table=dict( + columns=["id", "quote_id", "s", "t", "c"], + rows=[ + [h.hint_id, h.quote_id, h.anchor.s, h.anchor.t, h.anchor.c] + for h in catalog.hints + ], + ), + domains=[d.model_dump(mode="json") for d in catalog.domains], + role_legend=dict(ROLE_LEGEND), + role_order=ROLE_ORDER, + state_legend=dict(STATE_LEGEND), + source_inventory_exhaustive=False, + ) + except RoleLinkCatalogError: + raise + except Exception: + raise RoleLinkCatalogError() from None diff --git a/packages/agents/src/issuedigest_agents/role_link_contract.py b/packages/agents/src/issuedigest_agents/role_link_contract.py new file mode 100644 index 0000000..5561dd4 --- /dev/null +++ b/packages/agents/src/issuedigest_agents/role_link_contract.py @@ -0,0 +1,265 @@ +"""Pure explicit same-quote relation reports; locations never certify semantic truth.""" + +from typing import Annotated, Any, Literal, cast + +from issuedigest_domain.sources import Sha256 +from pydantic import BaseModel, Field + +from .balanced_role_generation import RoleAliasGenerationV2 +from .contextual_anchor import ContextualAnchor +from .numeric_candidates import ExactSpan +from .numeric_candidates_v2 import NumericCandidateV2 +from .numeric_evidence import Source, _StrictModel +from .numeric_selection_evaluation import _read +from .role_link_catalog import ( + MATRIX_ENCODING_VERSION, + ROLE_LEGEND, + ROLE_ORDER, + STATE_LEGEND, + RoleLinkCatalog, + RoleLinkHint, + _actual, + _hash, + _required_raw, + build_role_link_catalog, + role_link_encoding_metadata, + validate_role_link_catalog, +) +from .role_table_packet import _copy_json, _same_json +from .semantic_role_audit_contract import _frame +from .shared_measurement_context_v2 import _broad, _resolve + +type LinkRole = Literal[ + "measurement_scope", + "measurement_period", + "operator", + "reporter", + "entity_qualifier", + "programme_identity", + "comparison_baseline", + "purpose", +] +type LinkState = Literal[ + "classified_roles", "explicitly_unrelated", "linkage_uncertain", "not_reviewed" +] +type LinkStatus = Literal[ + "not_applicable", + "candidate_inventory_empty", + "candidate_review_incomplete", + "candidate_review_uncertain", + "reviewed_candidates", +] +type Count = Annotated[int, Field(ge=0)] + + +class RoleLinkContractError(ValueError): + """Sanitized whole-operation rejection with no source or partial report.""" + + def __init__(self) -> None: + super().__init__("Invalid role link contract") + + +class RoleLinkInput(_StrictModel): + rows: tuple[str, ...] + incomplete: bool + + +class RoleLinkDecision(_StrictModel): + hint_id: str + number: str + state: LinkState + roles: tuple[LinkRole, ...] + quantity: ExactSpan + hint: ExactSpan + support: ExactSpan + support_sentence_ids: tuple[str, ...] + + +class RoleLinkCoverage(_StrictModel): + selected_numbers: Count + total_hints: Count + candidate_pairs: Count + classified_pairs: Count + unrelated_pairs: Count + uncertain_pairs: Count + unreviewed_pairs: Count + + +class RoleLinkReport(_StrictModel): + profile: Literal["role-link-report-v1"] + encoding_version: Literal["role-link-matrix-v1"] + role_order: Literal["STOREIBP"] + source_inventory_exhaustive: Literal[False] + catalog: RoleLinkCatalog + catalog_sha256: Sha256 + domain_sha256: Sha256 + encoding_sha256: Sha256 + input: RoleLinkInput + decisions: tuple[RoleLinkDecision, ...] + coverage: RoleLinkCoverage + status: LinkStatus + + +def _original(value: Any, annotation: Any) -> None: + if isinstance(value, BaseModel): + _actual(value, annotation) + else: + _copy_json(value) + _required_raw(value, annotation) + + +def _decode(cell: str) -> tuple[LinkState, tuple[LinkRole, ...]]: + if cell in STATE_LEGEND: + return cast(LinkState, STATE_LEGEND[cell]), () + if not cell or any(c not in ROLE_ORDER for c in cell): + raise ValueError("Unknown relation cell") + if cell != "".join(c for c in ROLE_ORDER if c in cell): + raise ValueError("Roles must be unique and follow explicit role order") + return "classified_roles", tuple(cast(LinkRole, ROLE_LEGEND[c]) for c in cell) + + +def _locations( + catalog: RoleLinkCatalog, hint: RoleLinkHint, candidate: NumericCandidateV2 +) -> tuple[ExactSpan, tuple[str, ...]]: + index = _frame(catalog.generation).candidate_index + anchor = ContextualAnchor( + sentence_id=hint.anchor.s, text=hint.anchor.t, context_text=hint.anchor.c + ) + located = _resolve(index, anchor).span + context = _broad(index, anchor).span + quantity = candidate.expression + if located != hint.span or quantity.citation_id != located.citation_id: + raise ValueError("Pair location differs from current quote") + quote = next(q.quote for q in index.citations if q.citation_id == located.citation_id) + for span in (quantity, located, context): + if span.citation_id != located.citation_id or quote[span.start : span.end] != span.text: + raise ValueError("Span differs from exact current source") + left, right = min(quantity.start, context.start), max(quantity.end, context.end) + sentences = tuple( + s + for s in index.sentences + if s.span.citation_id == located.citation_id and s.span.start < right and s.span.end > left + ) + if not sentences or sentences[0].span.start > left or sentences[-1].span.end < right: + raise ValueError("Missing current covering sentences") + if any(a.span.end != b.span.start for a, b in zip(sentences, sentences[1:], strict=False)): + raise ValueError("Support is not contiguous") + start, end = sentences[0].span.start, sentences[-1].span.end + return ExactSpan( + citation_id=located.citation_id, text=quote[start:end], start=start, end=end + ), tuple(s.sentence_id for s in sentences) + + +def _reconstruct(catalog: RoleLinkCatalog, data: RoleLinkInput) -> RoleLinkReport: + if len(data.rows) != len(catalog.hints): + raise ValueError("Rows must cover every current hint in order") + domains = {d.quote_id: d for d in catalog.domains} + candidates = {c.candidate_id: c for c in _frame(catalog.generation).candidate_index.candidates} + decisions: list[RoleLinkDecision] = [] + for hint, row in zip(catalog.hints, data.rows, strict=True): + numbers = domains[hint.quote_id].numbers + if not numbers: + if row != "": + raise ValueError("Zero columns require an empty row") + continue + cells = row.split(";") + if len(cells) != len(numbers): + raise ValueError("Columns must cover exact current quote domain") + for number, cell in zip(numbers, cells, strict=True): + state, roles = _decode(cell) + if state == "not_reviewed" and not data.incomplete: + raise ValueError("Unreviewed requires incomplete") + candidate = candidates[number] + support, sentence_ids = _locations(catalog, hint, candidate) + decisions.append( + RoleLinkDecision( + hint_id=hint.hint_id, + number=number, + state=state, + roles=roles, + quantity=candidate.expression, + hint=hint.span, + support=support, + support_sentence_ids=sentence_ids, + ) + ) + selected = len(catalog.generation.frame.input.bindings) + if not decisions and data.incomplete: + raise ValueError("Zero-pair carrier must be complete") + counts = { + state: sum(d.state == state for d in decisions) + for state in ( + "classified_roles", + "explicitly_unrelated", + "linkage_uncertain", + "not_reviewed", + ) + } + status: LinkStatus = ( + "not_applicable" + if not selected + else "candidate_inventory_empty" + if not decisions + else "candidate_review_incomplete" + if data.incomplete + else "candidate_review_uncertain" + if counts["linkage_uncertain"] + else "reviewed_candidates" + ) + return RoleLinkReport( + profile="role-link-report-v1", + encoding_version=cast(Literal["role-link-matrix-v1"], MATRIX_ENCODING_VERSION), + role_order=cast(Literal["STOREIBP"], ROLE_ORDER), + source_inventory_exhaustive=False, + catalog=catalog, + catalog_sha256=_hash(catalog.model_dump(mode="json")), + domain_sha256=_hash([d.model_dump(mode="json") for d in catalog.domains]), + encoding_sha256=_hash(role_link_encoding_metadata()), + input=data, + decisions=tuple(decisions), + coverage=RoleLinkCoverage( + selected_numbers=selected, + total_hints=len(catalog.hints), + candidate_pairs=len(decisions), + classified_pairs=counts["classified_roles"], + unrelated_pairs=counts["explicitly_unrelated"], + uncertain_pairs=counts["linkage_uncertain"], + unreviewed_pairs=counts["not_reviewed"], + ), + status=status, + ) + + +def build_role_link_report( + source: Source, generation: RoleAliasGenerationV2, data: RoleLinkInput | dict[str, Any] +) -> RoleLinkReport: + """Decode every explicit cell and reconstruct full current same-quote locations.""" + try: + # Guard all originals before any dependency can serialize a different input. + _actual(source, Source) + _actual(generation, RoleAliasGenerationV2) + _original(data, RoleLinkInput) + catalog = build_role_link_catalog(source, generation) + return _reconstruct(catalog, _read(RoleLinkInput, data)) + except Exception: + raise RoleLinkContractError() from None + + +def validate_role_link_report( + source: Source, result: RoleLinkReport | dict[str, Any] +) -> RoleLinkReport: + """Rebuild the original catalog and every report field from current source.""" + try: + _actual(source, Source) + _original(result, RoleLinkReport) + original = result.catalog if isinstance(result, BaseModel) else result["catalog"] + catalog = validate_role_link_catalog(source, original) + checked = _read(RoleLinkReport, result) + expected = _reconstruct(catalog, checked.input) + canonical = expected.model_dump(mode="json") + _same_json(checked.model_dump(mode="json"), canonical) + if type(result) is dict: + _same_json(result, canonical) + return expected + except Exception: + raise RoleLinkContractError() from None diff --git a/packages/agents/tests/test_role_link_catalog.py b/packages/agents/tests/test_role_link_catalog.py new file mode 100644 index 0000000..b654034 --- /dev/null +++ b/packages/agents/tests/test_role_link_catalog.py @@ -0,0 +1,425 @@ +"""Synthetic surface inventories preserve locations; they do not establish semantics.""" + +import importlib +import importlib.util +import json +import re +from copy import deepcopy +from hashlib import sha256 + +import pytest +from pydantic import BaseModel, Field, create_model +from test_numeric_evidence import document +from test_semantic_role_audit_contract import generation +from test_shared_measurement_context_v2 import ( + restore_introduced_modules as restore_introduced_modules, +) + + +def api(): + name = "issuedigest_agents.role_link_catalog" + assert importlib.util.find_spec(name) is not None, "Missing pure role link catalog" + return importlib.import_module(name) + + +def original(*quotes, numbers=()): + # The existing balanced generation contract requires at least one candidate. + if not any(re.search(r"[0-9]", quote) for quote in quotes): + quotes = (*quotes[:-1], quotes[-1] + " 3명.") + src = document(*quotes) + data = dict(contexts=[], events=[], bindings=[], relations=[]) + if numbers: + data["contexts"] = [ + dict( + id="c1", + event="e1", + entity=None, + scopes=[], + period=None, + status="unknown", + condition=None, + purpose=None, + basis=None, + baseline=None, + ) + ] + data["events"] = [ + dict(id="e1", kind="unknown", actor=None, predicate=None, tense="unknown") + ] + data["bindings"] = [ + dict(number=n, context="c1", scopes=[], status_sentence=None, change=None) + for n in numbers + ] + return src, generation(src, data) + + +def test_literal_projection_keeps_full_packet_and_required_order_but_hides_origins(): + mod = api() + src, gen = original("현재 3명.", numbers=("n1",)) + result = mod.build_role_link_catalog(src, gen) + packet = mod.role_link_packet(src, gen) + table = importlib.import_module("issuedigest_agents.role_table_packet") + assert packet == dict( + profile="role-link-review-v1", + source_packet=table.build_role_table_packet(src, gen), + hint_table=dict( + columns=["id", "quote_id", "s", "t", "c"], rows=[["m1", "q1", "s1", "현재", None]] + ), + domains=[dict(quote_id="q1", numbers=["n1"], hints=["m1"])], + role_order="STOREIBP", + role_legend=dict( + S="measurement_scope", + T="measurement_period", + O="operator", + R="reporter", + E="entity_qualifier", + I="programme_identity", + B="comparison_baseline", + P="purpose", + ), + state_legend={"-": "explicitly_unrelated", "?": "linkage_uncertain", "_": "not_reviewed"}, + source_inventory_exhaustive=False, + ) + assert result.generation == gen + + def canonical(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + assert ( + result.generation_sha256 + == sha256(canonical(gen.model_dump(mode="json")).encode()).hexdigest() + ) + assert ( + result.source_packet_sha256 + == sha256(canonical(packet["source_packet"]).encode()).hexdigest() + ) + assert mod.validate_role_link_catalog(src, result) == result + assert mod.validate_role_link_catalog(src, json.loads(result.model_dump_json())) == result + encoding = mod.role_link_encoding_metadata() + assert encoding["encoding_version"] == "role-link-matrix-v1" + assert encoding["role_order"] == "STOREIBP" + encoding["role_legend"]["S"] = "altered" + packet["role_legend"]["S"] = "altered" + assert mod.role_link_encoding_metadata()["role_legend"]["S"] == "measurement_scope" + + +@pytest.mark.parametrize( + "text,expected", + [ + ( + "서울·부산", + [("서울", ("dot_member",)), ("서울·부산", ("dot_list",)), ("부산", ("dot_member",))], + ), + ( + "2026년간 2년까지 3년부터 4년", + [ + ("2026년간", ("year_surface",)), + ("2년까지", ("year_surface",)), + ("3년부터", ("year_surface",)), + ("4년", ("year_surface",)), + ], + ), + ( + "올해 3월부터 전년도 대비 현재", + [ + ("올해 3월부터", ("relative_time_surface",)), + ("전년도", ("place_suffix_surface",)), + ("전년도 대비", ("relative_time_surface",)), + ("현재", ("relative_time_surface",)), + ], + ), + ( + "합성부는 합성청에서 합성공단으로 합성위원회가 합성정부의 합성대학에", + [ + (word, ("institution_suffix_surface",)) + for word in ("합성부", "합성청", "합성공단", "합성위원회", "합성정부", "합성대학") + ], + ), + ( + "합성특별자치도는 합성특별시가 합성광역시에 합성도로 합성시에서 합성군의 합성구와", + [ + (word, ("place_suffix_surface",)) + for word in ( + "합성특별자치도", + "합성특별시", + "합성광역시", + "합성도", + "합성시", + "합성군", + "합성구", + ) + ], + ), + ( + "'사업' ‘계획’ 「목적」", + [(word, ("quoted_phrase",)) for word in ("'사업'", "‘계획’", "「목적」")], + ), + ( + "전년도·현재", + [ + ("전년도", ("dot_member", "place_suffix_surface", "relative_time_surface")), + ("전년도·현재", ("dot_list",)), + ("현재", ("dot_member", "relative_time_surface")), + ], + ), + ], +) +def test_exact_lexical_patterns_and_same_position_origin_dedup(text, expected): + mod = api() + src, gen = original(text) + result = mod.build_role_link_catalog(src, gen) + assert [(h.anchor.t, h.origins) for h in result.hints] == expected + assert [h.hint_id for h in result.hints] == [f"m{i}" for i in range(1, len(expected) + 1)] + for h in result.hints: + assert text[h.span.start : h.span.end] == h.anchor.t + + +def test_repeated_overlapping_phrase_uses_shortest_context_then_left_position(): + mod = api() + src, gen = original("현재현재") + result = mod.build_role_link_catalog(src, gen) + assert [(h.span.start, h.span.end, h.anchor.c) for h in result.hints] == [ + (0, 2, "현재현"), + (2, 4, "재현재"), + ] + + +def test_domains_follow_quote_and_binding_order_including_empty_domains_and_sentences(): + mod = api() + src, gen = original("\n현재 3명. 4명.\n", "올해 5명.", "설명만.", numbers=("n3", "n2", "n1")) + result = mod.build_role_link_catalog(src, gen) + assert [d.model_dump(mode="json") for d in result.domains] == [ + dict(quote_id="q1", numbers=["n2", "n1"], hints=["m1"]), + dict(quote_id="q2", numbers=["n3"], hints=["m2"]), + dict(quote_id="q3", numbers=[], hints=[]), + ] + assert result.hints[0].anchor.s == "s2" + assert result.hints[0].span.start == 1 + for text in ("설명만.", "현재 3명."): + empty_src, empty_gen = original(text) + body = mod.role_link_packet(empty_src, empty_gen) + assert body["domains"][0]["numbers"] == [] + assert ( + "".join(r[1] for r in body["source_packet"]["quotes"][0]["sentences"]["rows"]) + == empty_src[1].citations[0].selector.quote + ) + + +@pytest.mark.parametrize("count,reason", [(24, None), (25, "candidate_overflow")]) +def test_per_quote_hint_limit_is_inclusive_and_does_not_truncate(count, reason): + mod = api() + src, gen = original(" ".join(f"'항목{chr(65 + i)}'" for i in range(count))) + if reason: + with pytest.raises(mod.RoleLinkCatalogError) as caught: + mod.build_role_link_catalog(src, gen) + assert caught.value.reason == reason + else: + assert len(mod.build_role_link_catalog(src, gen).hints) == count + + +@pytest.mark.parametrize("length,reason", [(998, None), (999, "unlocatable_hint")]) +def test_anchor_length_limit_is_exact_and_error_is_sanitized(length, reason): + mod = api() + text = "'" + "가" * length + "'" + src, gen = original(text) + if reason: + with pytest.raises(mod.RoleLinkCatalogError) as caught: + mod.build_role_link_catalog(src, gen) + assert caught.value.reason == reason + assert text not in str(caught.value) + assert caught.value.__cause__ is None + else: + assert mod.build_role_link_catalog(src, gen).hints[0].anchor.t == text + + +def test_overflow_checked_before_unlocatable_hint_and_applies_to_each_quote(): + mod = api() + src, gen = original("'" + "가" * 1001 + "' " + " ".join("현재" for _ in range(24))) + with pytest.raises(mod.RoleLinkCatalogError) as caught: + mod.build_role_link_catalog(src, gen) + assert caught.value.reason == "candidate_overflow" + quote = " ".join(f"'항목{chr(65 + i)}'" for i in range(24)) + src, gen = original(quote, quote) + assert len(mod.build_role_link_catalog(src, gen).hints) == 48 + + +def test_unresolvable_repeated_anchor_fails_instead_of_omitting(): + mod = api() + src, gen = original("'" + "a" * 998 + "'" + "'" + "a" * 998 + "'") + with pytest.raises(mod.RoleLinkCatalogError) as caught: + mod.build_role_link_catalog(src, gen) + assert caught.value.reason == "unlocatable_hint" + + +class StringSubclass(str): + pass + + +class ListSubclass(list): + pass + + +class DictSubclass(dict): + pass + + +class TupleSubclass(tuple): + pass + + +@pytest.mark.parametrize( + "mutation", + [ + "profile", + "rules_version", + "source_inventory_exhaustive", + "generation_sha256", + "source_packet_sha256", + "rules_sha256", + "hint_id", + "quote_id", + "span", + "origins", + "anchor", + "numbers", + "hints", + "domain_quote", + "missing_hint", + "extra_domain", + ], +) +def test_reconstruction_rejects_all_catalog_and_domain_drift(mutation): + mod = api() + src, gen = original("현재 3명.", numbers=("n1",)) + body = mod.build_role_link_catalog(src, gen).model_dump(mode="json") + if mutation in ( + "profile", + "rules_version", + "generation_sha256", + "source_packet_sha256", + "rules_sha256", + ): + body[mutation] = "altered" + elif mutation == "source_inventory_exhaustive": + body[mutation] = True + elif mutation in ("hint_id", "quote_id"): + body["hints"][0][mutation] = "other" + elif mutation == "span": + body["hints"][0]["span"]["start"] = 1 + elif mutation == "origins": + body["hints"][0]["origins"] = ["year_surface"] + elif mutation == "anchor": + body["hints"][0]["anchor"]["c"] = "현재 3명" + elif mutation in ("numbers", "hints"): + body["domains"][0][mutation] = [] + elif mutation == "domain_quote": + body["domains"][0]["quote_id"] = "q2" + elif mutation == "missing_hint": + body["hints"] = [] + elif mutation == "extra_domain": + body["domains"].append(deepcopy(body["domains"][0])) + with pytest.raises(mod.RoleLinkCatalogError): + mod.validate_role_link_catalog(src, body) + + +@pytest.mark.parametrize("mode", ["actual", "raw"]) +@pytest.mark.parametrize( + "mutation", + [ + "missing_profile", + "missing_rules", + "missing_flag", + "missing_generation_marker", + "missing_index_marker", + "missing_nullable", + "missing_default", + "string_subclass", + "tuple_list", + "container_subclass", + "model_subclass", + "raw_nested", + "bool_int", + ], +) +def test_strict_boundaries_reject_before_any_dump(mode, mutation, monkeypatch): + mod = api() + src, gen = original("현재 3명.", numbers=("n1",)) + body = mod.build_role_link_catalog(src, gen) + if mode == "raw": + body = body.model_dump(mode="json") + + def fields(node): + return node.__dict__ if isinstance(node, BaseModel) else node + + root = fields(body) + stored = fields(root["generation"]) + frame = fields(stored["frame"]) + index = fields(fields(frame["frame"])["candidate_index"]) + hint = fields(root["hints"][0]) + if mutation in ("missing_profile", "missing_rules", "missing_flag"): + del root[ + { + "missing_profile": "profile", + "missing_rules": "rules_version", + "missing_flag": "source_inventory_exhaustive", + }[mutation] + ] + elif mutation == "missing_generation_marker": + del stored["status"] + elif mutation == "missing_index_marker": + del index["status"] + elif mutation == "missing_nullable": + del fields(hint["anchor"])["c"] + elif mutation == "missing_default": + del src[1].document.__dict__["canonical_url"] + elif mutation == "string_subclass": + hint["hint_id"] = StringSubclass("m1") + elif mutation == "tuple_list": + root["hints"] = list(root["hints"]) if mode == "actual" else tuple(root["hints"]) + elif mutation == "container_subclass": + root["hints"] = ( + TupleSubclass(root["hints"]) if mode == "actual" else ListSubclass(root["hints"]) + ) + elif mutation == "model_subclass": + if mode == "actual": + sub = create_model( + "ExcludedHint", + __base__=type(root["hints"][0]), + hidden=(str, Field(default="hidden", exclude=True)), + ) + root["hints"] = (sub.model_validate(root["hints"][0].model_dump()),) + else: + root["hints"][0] = DictSubclass(root["hints"][0]) + elif mutation == "raw_nested": + if mode == "actual": + hint["anchor"] = hint["anchor"].model_dump(mode="json") + else: + root["generation"] = gen + elif mutation == "bool_int": + fields(stored["usage"])["requests"] = True + calls = [] + + def forbidden(*args, **kwargs): + calls.append(True) + raise RuntimeError("must guard before serialization") + + monkeypatch.setattr(BaseModel, "model_dump", forbidden) + monkeypatch.setattr(BaseModel, "model_dump_json", forbidden) + with pytest.raises(mod.RoleLinkCatalogError) as caught: + mod.validate_role_link_catalog(src, body) + assert caught.value.reason == "invalid_input" + assert calls == [] + + +def test_current_source_and_generation_changes_cannot_reuse_catalog(): + mod = api() + src, gen = original("현재 3명.", numbers=("n1",)) + result = mod.build_role_link_catalog(src, gen) + changed = deepcopy(src) + changed[1].representation.__dict__["text"] += " " + with pytest.raises(mod.RoleLinkCatalogError): + mod.validate_role_link_catalog(changed, result) + forged = result.model_copy(deep=True) + forged.generation.usage.__dict__["input_tokens"] += 1 + with pytest.raises(mod.RoleLinkCatalogError): + mod.validate_role_link_catalog(src, forged) diff --git a/packages/agents/tests/test_role_link_contract.py b/packages/agents/tests/test_role_link_contract.py new file mode 100644 index 0000000..b2256a8 --- /dev/null +++ b/packages/agents/tests/test_role_link_contract.py @@ -0,0 +1,475 @@ +"""Synthetic explicit matrix coverage; no classification accuracy claims.""" + +import importlib +import importlib.util +import json +from copy import deepcopy +from hashlib import sha256 + +import pytest +from pydantic import BaseModel, Field, create_model +from test_role_link_catalog import original +from test_shared_measurement_context_v2 import ( + restore_introduced_modules as restore_introduced_modules, +) + + +def api(): + name = "issuedigest_agents.role_link_contract" + assert importlib.util.find_spec(name) is not None, "Missing pure role link contract" + return importlib.import_module(name) + + +def build(text="현재 3명.", *, rows=("SO",), incomplete=False, numbers=("n1",)): + mod = api() + src, gen = original(text, numbers=numbers) + return ( + mod, + src, + gen, + mod.build_role_link_report(src, gen, dict(rows=list(rows), incomplete=incomplete)), + ) + + +def digest(value): + return sha256( + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +@pytest.mark.parametrize( + "cell,incomplete,state,roles,status", + [ + ("SO", False, "classified_roles", ("measurement_scope", "operator"), "reviewed_candidates"), + ( + "STOREIBP", + False, + "classified_roles", + ( + "measurement_scope", + "measurement_period", + "operator", + "reporter", + "entity_qualifier", + "programme_identity", + "comparison_baseline", + "purpose", + ), + "reviewed_candidates", + ), + ("-", False, "explicitly_unrelated", (), "reviewed_candidates"), + ("?", False, "linkage_uncertain", (), "candidate_review_uncertain"), + ("_", True, "not_reviewed", (), "candidate_review_incomplete"), + ("?", True, "linkage_uncertain", (), "candidate_review_incomplete"), + ("S", True, "classified_roles", ("measurement_scope",), "candidate_review_incomplete"), + ], +) +def test_explicit_states_roles_and_conservative_status(cell, incomplete, state, roles, status): + mod, src, gen, report = build(rows=(cell,), incomplete=incomplete) + assert report.profile == "role-link-report-v1" + assert report.encoding_version == "role-link-matrix-v1" + assert report.role_order == "STOREIBP" + assert report.source_inventory_exhaustive is False + assert report.catalog.generation == gen + assert report.status == status + (decision,) = report.decisions + assert (decision.hint_id, decision.number, decision.state, decision.roles) == ( + "m1", + "n1", + state, + roles, + ) + assert report.coverage.model_dump(mode="json") == dict( + selected_numbers=1, + total_hints=1, + candidate_pairs=1, + classified_pairs=int(state == "classified_roles"), + unrelated_pairs=int(state == "explicitly_unrelated"), + uncertain_pairs=int(state == "linkage_uncertain"), + unreviewed_pairs=int(state == "not_reviewed"), + ) + cat = importlib.import_module("issuedigest_agents.role_link_catalog") + assert report.catalog_sha256 == digest(report.catalog.model_dump(mode="json")) + assert report.domain_sha256 == digest( + [d.model_dump(mode="json") for d in report.catalog.domains] + ) + assert report.encoding_sha256 == digest(cat.role_link_encoding_metadata()) + assert mod.validate_role_link_report(src, report) == report + assert mod.validate_role_link_report(src, json.loads(report.model_dump_json())) == report + actual_input = mod.RoleLinkInput(rows=(cell,), incomplete=incomplete) + assert mod.build_role_link_report(src, gen, actual_input) == report + + +@pytest.mark.parametrize( + "cell", ["", "OS", "SS", "s", "X", "S ", " S", "S?", "-_", "S;", ";S", "S;;T", "-;?"] +) +def test_bad_cell_order_symbols_and_separator_gaps_are_rejected(cell): + mod = api() + src, gen = original("현재 3명.", numbers=("n1",)) + with pytest.raises(mod.RoleLinkContractError): + mod.build_role_link_report(src, gen, dict(rows=[cell], incomplete=False)) + + +def test_unreviewed_requires_incomplete_and_missing_rows_or_columns_never_fill(): + mod = api() + src, gen = original("현재 3명. 올해 4명.", numbers=("n2", "n1")) + for rows in (["_;-", "-;-"], [], ["S;T"], ["S", "T"], ["S;T", "S;T", "S;T"]): + with pytest.raises(mod.RoleLinkContractError): + mod.build_role_link_report(src, gen, dict(rows=rows, incomplete=False)) + report = mod.build_role_link_report(src, gen, dict(rows=["SO;-", "?;_"], incomplete=True)) + assert [(d.hint_id, d.number, d.state) for d in report.decisions] == [ + ("m1", "n2", "classified_roles"), + ("m1", "n1", "explicitly_unrelated"), + ("m2", "n2", "linkage_uncertain"), + ("m2", "n1", "not_reviewed"), + ] + assert report.coverage.model_dump(mode="json") == dict( + selected_numbers=2, + total_hints=2, + candidate_pairs=4, + classified_pairs=1, + unrelated_pairs=1, + uncertain_pairs=1, + unreviewed_pairs=1, + ) + + +@pytest.mark.parametrize( + "selected,status,rows", + [((), "not_applicable", [""]), (("n1",), "candidate_inventory_empty", [])], +) +def test_zero_pair_carriers_require_exact_rows_and_complete(selected, status, rows): + mod = api() + src, gen = original("현재 3명." if not selected else "3명.", numbers=selected) + report = mod.build_role_link_report(src, gen, dict(rows=rows, incomplete=False)) + assert report.status == status + assert report.decisions == () + assert report.coverage.selected_numbers == len(selected) + for bad in ( + dict(rows=rows, incomplete=True), + dict(rows=["-"] if not selected else [""], incomplete=False), + ): + with pytest.raises(mod.RoleLinkContractError): + mod.build_role_link_report(src, gen, bad) + + +def test_multiquote_domains_keep_zero_columns_without_erasing_other_pairs(): + mod = api() + src, gen = original("현재 설명.", "올해 3명.", "4명.", numbers=("n2", "n1")) + report = mod.build_role_link_report(src, gen, dict(rows=["", "S"], incomplete=False)) + assert report.status == "reviewed_candidates" + assert [(d.hint_id, d.number) for d in report.decisions] == [("m2", "n1")] + assert report.coverage.model_dump(mode="json") == dict( + selected_numbers=2, + total_hints=2, + candidate_pairs=1, + classified_pairs=1, + unrelated_pairs=0, + uncertain_pairs=0, + unreviewed_pairs=0, + ) + for rows in (["-", "S"], ["", "S;S"], ["S", ""]): + with pytest.raises(mod.RoleLinkContractError): + mod.build_role_link_report(src, gen, dict(rows=rows, incomplete=False)) + + +def test_full_quantity_whole_minimal_support_and_repeated_hint_context(): + text = "앞말.\n현재현재 설명.\n\n중간.\n약 3명~5명 이상.\n뒷말." + mod, src, _, report = build(text, rows=("-", "?")) + for d, expected_start in zip(report.decisions, (4, 6), strict=True): + assert d.quantity.text == "약 3명~5명 이상" + assert d.hint.text == "현재" + assert d.hint.start == expected_start + assert d.hint.end == expected_start + 2 + assert d.support.text == "현재현재 설명.\n\n중간.\n약 3명~5명 이상." + assert d.support_sentence_ids == ("s3", "s4", "s5", "s6", "s7", "s8") + assert d.support.start == 4 + assert d.support.end == text.index("뒷말") - 1 + assert mod.validate_role_link_report(src, report) == report + + +def test_support_over_twenty_sentences_is_kept_in_own_report(): + text = "현재.\n" + "중간.\n" * 22 + "3명.\n끝." + _, _, _, report = build(text, rows=("?",)) + assert len(report.decisions[0].support_sentence_ids) > 20 + assert report.decisions[0].support.text == text[: text.index("끝") - 1] + assert report.decisions[0].support_sentence_ids[0] == "s1" + + +def test_exact_288_pair_boundary_has_no_legacy_144_cap(): + text = ( + " ".join(f"'item{chr(65 + i)}'" for i in range(24)) + + " " + + " ".join(f"{i}명." for i in range(1, 13)) + ) + mod, src, _, report = build( + text, rows=(";".join(["?"] * 12),) * 24, numbers=tuple(f"n{i}" for i in range(1, 13)) + ) + assert len(report.decisions) == 288 + assert report.coverage.candidate_pairs == report.coverage.uncertain_pairs == 288 + assert (report.decisions[-1].hint_id, report.decisions[-1].number) == ("m24", "n12") + assert mod.validate_role_link_report(src, report) == report + + +class StringSubclass(str): + pass + + +class ListSubclass(list): + pass + + +class TupleSubclass(tuple): + pass + + +class DictSubclass(dict): + pass + + +def fields(value): + return value.__dict__ if isinstance(value, BaseModel) else value + + +def node_at(value, path): + for part in path: + value = fields(value)[part] + return value + + +TAMPERS = [ + (("profile",), "other"), + (("encoding_version",), "other"), + (("role_order",), "BES"), + (("source_inventory_exhaustive",), True), + (("catalog_sha256",), "0" * 64), + (("domain_sha256",), "0" * 64), + (("encoding_sha256",), "0" * 64), + (("status",), "candidate_review_uncertain"), + (("catalog", "profile"), "other"), + (("catalog", "generation_sha256"), "0" * 64), + (("catalog", "source_packet_sha256"), "0" * 64), + (("catalog", "rules_sha256"), "0" * 64), + (("catalog", "domains", 0, "quote_id"), "q2"), + (("catalog", "hints", 0, "anchor", "t"), "다른"), + (("catalog", "generation", "usage", "input_tokens"), 101), + (("input", "incomplete"), True), + (("decisions", 0, "hint_id"), "m2"), + (("decisions", 0, "number"), "n2"), + (("decisions", 0, "state"), "explicitly_unrelated"), + (("decisions", 0, "quantity", "text"), "4명"), + (("decisions", 0, "quantity", "start"), 2), + (("decisions", 0, "hint", "text"), "다른"), + (("decisions", 0, "support", "start"), 1), + (("decisions", 0, "support", "citation_id"), "other"), + *( + (("coverage", key), 9) + for key in ( + "selected_numbers", + "total_hints", + "candidate_pairs", + "classified_pairs", + "unrelated_pairs", + "uncertain_pairs", + "unreviewed_pairs", + ) + ), +] + + +@pytest.mark.parametrize("mode", ["actual", "raw"]) +@pytest.mark.parametrize("path,replacement", TAMPERS) +def test_every_report_catalog_decision_count_and_hash_is_reconstructed(mode, path, replacement): + mod, src, _, report = build() + body = report if mode == "actual" else report.model_dump(mode="json") + fields(node_at(body, path[:-1]))[path[-1]] = replacement + with pytest.raises(mod.RoleLinkContractError) as caught: + mod.validate_role_link_report(src, body) + assert caught.value.__cause__ is None + assert "현재" not in str(caught.value) + + +@pytest.mark.parametrize("mode", ["actual", "raw"]) +@pytest.mark.parametrize( + "mutation", + [ + "roles", + "support_ids", + "rows", + "missing_decision", + "extra_decision", + "domain_numbers", + "input_cell", + ], +) +def test_reconstruction_rejects_sequence_and_input_drift(mode, mutation): + mod, src, _, report = build() + body = report if mode == "actual" else report.model_dump(mode="json") + root = fields(body) + decision = fields(root["decisions"][0]) + + def seq(items): + return tuple(items) if mode == "actual" else list(items) + + if mutation == "roles": + decision["roles"] = seq(["operator", "measurement_scope"]) + elif mutation == "support_ids": + decision["support_sentence_ids"] = seq(["s2"]) + elif mutation == "rows": + fields(root["input"])["rows"] = seq([]) + elif mutation == "input_cell": + fields(root["input"])["rows"] = seq(["?"]) + elif mutation == "domain_numbers": + fields(fields(root["catalog"])["domains"][0])["numbers"] = seq([]) + elif mutation == "missing_decision": + root["decisions"] = seq([]) + elif mutation == "extra_decision": + root["decisions"] = seq([*root["decisions"], deepcopy(root["decisions"][0])]) + with pytest.raises(mod.RoleLinkContractError): + mod.validate_role_link_report(src, body) + + +@pytest.mark.parametrize("mode", ["actual", "raw"]) +@pytest.mark.parametrize( + "mutation", + [ + "root_marker", + "generation_marker", + "index_marker", + "anchor_default", + "source_default", + "input_missing", + "coverage_missing", + "decision_missing", + "string_subclass", + "bool_int", + "int_bool", + "container_swap", + "container_subclass", + "model_subclass", + "nested_model", + ], +) +def test_nested_strict_guards_run_before_any_serialization(mode, mutation, monkeypatch): + mod, src, _, report = build() + body = report if mode == "actual" else report.model_dump(mode="json") + root = fields(body) + catalog = fields(root["catalog"]) + stored = fields(catalog["generation"]) + decision = fields(root["decisions"][0]) + if mutation == "root_marker": + del root["profile"] + elif mutation == "generation_marker": + del stored["status"] + elif mutation == "index_marker": + del fields(fields(fields(stored["frame"])["frame"])["candidate_index"])["status"] + elif mutation == "anchor_default": + del fields(fields(catalog["hints"][0])["anchor"])["c"] + elif mutation == "source_default": + del src[1].document.__dict__["canonical_url"] + elif mutation == "input_missing": + del fields(root["input"])["incomplete"] + elif mutation == "coverage_missing": + del fields(root["coverage"])["uncertain_pairs"] + elif mutation == "decision_missing": + del decision["roles"] + elif mutation == "string_subclass": + decision["number"] = StringSubclass("n1") + elif mutation == "bool_int": + fields(root["coverage"])["classified_pairs"] = True + elif mutation == "int_bool": + fields(root["input"])["incomplete"] = 0 + elif mutation == "container_swap": + root["decisions"] = ( + list(root["decisions"]) if mode == "actual" else tuple(root["decisions"]) + ) + elif mutation == "container_subclass": + root["decisions"] = ( + TupleSubclass(root["decisions"]) + if mode == "actual" + else ListSubclass(root["decisions"]) + ) + elif mutation == "model_subclass": + if mode == "actual": + subtype = create_model( + "ExcludedDecision", + __base__=type(root["decisions"][0]), + hidden=(str, Field(default="hidden", exclude=True)), + ) + root["decisions"] = (subtype.model_validate(root["decisions"][0].model_dump()),) + else: + root["decisions"][0] = DictSubclass(root["decisions"][0]) + elif mutation == "nested_model": + decision["quantity"] = ( + decision["quantity"].model_dump(mode="json") + if mode == "actual" + else report.decisions[0].quantity + ) + calls = [] + + def forbidden(*args, **kwargs): + calls.append(True) + raise RuntimeError("guard before dump") + + monkeypatch.setattr(BaseModel, "model_dump", forbidden) + monkeypatch.setattr(BaseModel, "model_dump_json", forbidden) + with pytest.raises(mod.RoleLinkContractError): + mod.validate_role_link_report(src, body) + assert calls == [] + + +@pytest.mark.parametrize( + "mutation", + [ + "missing", + "bool", + "rows_tuple", + "row_subclass", + "raw_model", + "actual_list", + "source_marker", + "gen_marker", + ], +) +def test_build_guards_all_original_inputs_before_any_dump(mutation, monkeypatch): + mod, src, gen, _ = build() + data = dict(rows=["SO"], incomplete=False) + if mutation == "missing": + del data["incomplete"] + elif mutation == "bool": + data["incomplete"] = 0 + elif mutation == "rows_tuple": + data["rows"] = ("SO",) + elif mutation == "row_subclass": + data["rows"] = [StringSubclass("SO")] + elif mutation == "raw_model": + data = DictSubclass(data) + elif mutation == "actual_list": + data = mod.RoleLinkInput(rows=("SO",), incomplete=False) + data.__dict__["rows"] = ["SO"] + elif mutation == "source_marker": + del src[1].document.__dict__["canonical_url"] + elif mutation == "gen_marker": + del gen.__dict__["status"] + calls = [] + + def forbidden(*args, **kwargs): + calls.append(True) + raise RuntimeError("guard before dump") + + monkeypatch.setattr(BaseModel, "model_dump", forbidden) + monkeypatch.setattr(BaseModel, "model_dump_json", forbidden) + with pytest.raises(mod.RoleLinkContractError): + mod.build_role_link_report(src, gen, data) + assert calls == [] + + +def test_current_source_drift_and_unchecked_negative_counts_are_rejected(): + mod, src, _, report = build() + changed = deepcopy(src) + changed[1].representation.__dict__["text"] += " " + with pytest.raises(mod.RoleLinkContractError): + mod.validate_role_link_report(changed, report) + report.coverage.__dict__["unreviewed_pairs"] = -1 + with pytest.raises(mod.RoleLinkContractError): + mod.validate_role_link_report(src, report)