edisco-graph

eDiscovery 커뮤니케이션 관계망을 온톨로지 기반 지식그래프로 재구성하고, 실무자가 탐색할 수 있게 만드는 도구.

100,827메시지 (사본 접은 뒤)
36,288Actor
32,840Person
4,299same_actor 쌍
126테스트 통과
최종 수정 2026-09-09 테스트 pytest 126 · vitest 30 코퍼스 Enron · 상위 15 커스토디언 · 240,577 파일 스택 Python · RDF/OWL · Cytoscape.js 진행 10개 중 7개 · Task 8 진행 중

00 확정된 것

브레인스토밍에서 결정된 전제. 뒤집으려면 아래 설계도 함께 바뀝니다.

항목결정
목적실무자가 실제로 쓰는 제품. 논문용 벤치마크가 아님
공략 지점인물·조직 관계망 분석 — 엔티티 해소와 커뮤니케이션 네트워크
입력 데이터Enron 공개 코퍼스로 시작. 실데이터 연동은 이후
핵심 동작그래프 시각적 탐색
온톨로지 수준하이브리드 — RDF/OWL로 정의, property graph로 투영
v1 범위수직 슬라이스. 커스토디언 상위 N명을 끝까지 관통 — N은 아래 실측을 보고 확정 중

01 코퍼스 실측 측정 완료

설계 단계의 추정치를 실제 데이터로 대체한 결과. 하나는 크게 틀렸습니다.

항목
배포본enron_mail_20150507.tar.gz · 443,254,787 bytes
커스토디언150명
전체 파일517,401
상위 15명 합계240,577 (전체의 46.5%)

스펙의 v1 규모 추정이 5배 틀렸습니다. 설계 문서에 "커스토디언 15~20명 ≈ 5만 통"이라 적었지만, 실제 상위 15명은 24만 파일입니다. 검증 없이 넣은 숫자였습니다. 상위 5명이 약 12.5만, 상위 3명이 약 8.2만, 상위 2명이 약 5.7만입니다. N은 15로 확정했습니다 — 추론기가 상한을 정하는 구조였는데, owlrl 을 버리면서 그 제약이 사라졌습니다 (아래 06 의 P2).

파일 수 상위 15 커스토디언

#커스토디언파일
1kaminski-v28,465
2dasovich-j28,234
3kean-s25,351
4mann-k23,381
5jones-t19,950
6shackleton-s18,687
7taylor-m13,875
8farmer-d13,032
9germany-c12,436
10beck-s11,830
11symes-k10,827
12nemec-g10,655
13scott-s8,022
14rogers-b8,009
15bass-e7,823

상위권이 임원이 아니라 리서치·대외협력·법무 담당이라는 점은 짚어둘 만합니다. 메일 수가 많은 사서함과 사건의 중심 인물은 다릅니다. 관계망 탐색 데모로는 문제없지만, "핵심 인물 자동 랭킹" 같은 기능을 나중에 붙일 때는 통신량만으로 중요도를 매길 수 없다는 근거가 됩니다.

02 아키텍처 확정

7단계 독립 모듈. 각 단계가 결과를 디스크에 남기므로 한 단계를 고쳐도 앞 단계를 다시 돌리지 않습니다.

Enron maildir
   │
   ├─[1] ingest    파싱 → 정규화된 Message 레코드          → SQLite
   ├─[2] extract   주소·표시이름·서명에서 Actor 후보 추출   → SQLite
   ├─[3] resolve   Actor 후보 → Person 클러스터            → SQLite
   ├─[4] ontology  RDF 트리플 생성 + OWL 추론 (owlrl)      → graph.ttl
   ├─[5] project   RDF → property graph 투영 + 지표 계산    → graph.pkl
   ├─[6] serve     FastAPI: ego-network 서브그래프 JSON
   └─[7] ui        Cytoscape.js 렌더링, 클릭 시 확장

하이브리드가 실현되는 지점은 [4]→[5]입니다. RDF/OWL이 온톨로지의 단일 진실 원천이고 추론도 여기서 일어납니다. property graph는 그 결과를 탐색용으로 투영한 것입니다. 단방향이라 두 모델을 양방향 동기화하는 문제가 생기지 않습니다.

Neo4j를 넣지 않는 이유

v1 규모(약 2만 노드 / 20만 엣지)는 networkx 인메모리로 충분합니다. Neo4j를 넣는 순간 Docker·드라이버·Cypher·RDF↔Neo4j 동기화가 따라옵니다. 성능이 실제로 아플 때 [5][6]만 교체하면 되도록 경계를 그어둡니다.

디렉터리

edisco-graph/
├── ontology/ediscovery.ttl    # OWL 스키마 — 사람이 읽고 고치는 파일
├── src/edisco/
│   ├── ingest.py  extract.py  resolve.py
│   └── ontology.py  project.py  api.py
├── web/index.html             # Cytoscape.js 단일 페이지
├── design/index.html          # 이 문서
├── data/                      # gitignore — 원본·중간산출물
└── tests/

프론트엔드는 React 없이 단일 HTML + Cytoscape.js. 빌드 도구도 없습니다. 필요해지면 그때 올립니다.

03 온톨로지와 엔티티 해소 확정

"온톨로지 썼습니다"가 장식이 되지 않으려면, 추론기가 없으면 직접 짜야 했을 코드를 대체해야 합니다.

추론이 밥값을 하는 네 지점

① 별칭 클러스터링 — 핵심, 그리고 측정이 뒤집은 곳

:sameActorAs a owl:SymmetricProperty, owl:TransitiveProperty .

엔티티 해소는 쌍 단위 판단만 내놓습니다. bsmith@enron.com ↔ bob.smith@enron.com, bob.smith@enron.com ↔ robert.smith@ect.enron.com. 이걸 사람 단위 클러스터로 묶는 일을 원래는 이 두 줄 선언으로 추론기에 맡길 계획이었습니다. 실측 결과 그 계획은 폐기했습니다 — 아래 06 섹션의 P2 를 보십시오.

폐포는 ontology.person_clusters 가 union-find 로 직접 계산하고, 결과는 쌍별 완전 그래프가 아니라 Person → resolvesTo → Actor 트리플로 저장합니다. 선언 두 줄은 온톨로지에 남깁니다 — 그게 sameActorAs 의 의미를 규정하는 명세이고, 추론기를 가진 외부 소비자가 우리 결과를 검증할 근거이기 때문입니다. 우리는 그 의미를 직접 구현합니다.

② 하위 속성으로 질의 단순화

:sentTo  rdfs:subPropertyOf :addressed .
:ccTo    rdfs:subPropertyOf :addressed .
:bccTo   rdfs:subPropertyOf :addressed .

"A가 B에게 보낸 모든 것"을 물을 때 세 속성을 OR로 묶지 않습니다. :replyTo가 추가돼도 질의는 그대로입니다.

③ 하위 클래스로 역할 계층:Executive ⊑ :Employee. "임원과 소통한 사람"에 CEO·CFO·VP가 자동 포함됩니다. :Counsel은 나중에 특권 문서 탐지로 확장할 때 재사용됩니다.

④ domain/range로 오류 검출:sentMessage의 domain이 :Person인데 :Organization이 들어오면 파이프라인 버그입니다. 일관성 검사가 무료 테스트가 됩니다.

핵심 클래스

클래스의미
:Actor원본에서 관찰된 신원 표현 — 이메일 주소 1개 + 표시이름들
:Person해소된 실제 사람. 여러 Actor가 하나로
:Organization조직·부서
:Message개별 이메일
:ThreadIn-Reply-To/References로 묶인 대화
:RoleExecutive / Trader / Counsel 등

ActorPerson을 분리하는 게 중요합니다. 원본에 있던 것(관찰)과 우리가 판단한 것(추론)을 섞으면, 나중에 "왜 이 둘을 같은 사람으로 봤나"에 답할 수 없습니다.

블로킹 — 전수 비교는 돌지 않는다

커스토디언 15명이면 actor 가 만 단위로 나옵니다. 모든 쌍을 비교하면 억 단위이고 쌍마다 SQL 을 때리므로 파이프라인이 끝나지 않습니다. 같은 블록에 든 쌍만 비교합니다.

블록 키는 표시이름의 성, 로컬파트 토큰, 그리고 로컬파트에서 첫 글자를 뗀 형태입니다. 마지막 것이 핵심입니다 — jskillingskilling 블록에 들어가서 표시이름 "Skilling, Jeff" 를 가진 jeff.skilling 과 만납니다. 60명을 넘는 블록은 버립니다 (enron 같은 토큰이 만드는 블록은 아무것도 구별해주지 않습니다).

블로킹은 재현율을 깎습니다. 버려진 블록 말고는 아무 키도 공유하지 않는 참 쌍은 후보에 아예 들어오지 못하고, 그러면 점수화 규칙을 어떻게 고쳐도 잡히지 않습니다. 그래서 평가가 재현율을 두 조각으로 나눠 보고합니다 — blocked_out(블로킹이 후보로 만들지도 않은 쌍)과 나머지(점수화가 놓친 쌍)입니다. 섞어놓으면 어디를 고쳐야 하는지 알 수 없습니다.

해소 4단계

  1. 정규화 — 주소 소문자화, 서브도메인 통합(@ect.enron.com → @enron.com), 표시이름을 성, 이름으로
  2. 강한 증거 → 즉시 병합 — 동일 주소, 또는 표시이름 완전일치 + 동일 도메인
  3. 약한 증거 → 점수화 — 이름 유사도(Jaro-Winkler), 로컬파트↔이름 매칭(bsmith ↔ Bob Smith), 그리고 공통 연락처 중복도 — 같은 사람들과 소통하면 동일인 확률이 오릅니다. 그래프 구조를 해소에 되먹이는 부분이고, 순수 문자열 매칭 대비 개선을 보여줄 지점입니다
  4. 임계값 초과 → :sameActorAs 생성 → union-find 가 Person 클러스터로 닫음. 클러스터가 상한(MAX_CLUSTER_SIZE)을 넘으면 자동 수용하지 않고 과병합 의심으로 따로 보고합니다 — 이행 폐포는 잘못된 쌍 하나로 두 클러스터를 이어붙이므로, 상한이 없으면 "이 500개 계정이 다 같은 사람"이라는 주장을 조용히 받아들이게 됩니다

근거 보존 — 법률 도메인에서는 필수

:merge_0142 a prov:Activity ;
    prov:used   :actor_bsmith, :actor_robert_smith ;
    :rule       "localpart_name_match" ;
    :confidence 0.87 .

실무자가 "이 두 계정을 왜 동일인으로 묶었냐"고 물으면 답할 수 있어야 합니다. 답 못 하는 자동화는 법률 영역에서 안 쓰입니다. 그리고 이 기록이 있으면 신뢰도 낮은 병합만 골라 사람이 검토하는 화면을 만들 수 있습니다.

정확도 확인

수작업 라벨링한 골드셋 250쌍으로 precision/recall을 측정합니다. 표본은 세 갈래입니다 — 신뢰도 최하위 병합 100쌍(정밀도), 후보에는 들어왔지만 임계값을 못 넘은 100쌍(점수화 재현율), 그리고 블로킹 밖의 동일-성 50쌍(블로킹 재현율). 세 번째가 없으면 blocked_out 이 셀 대상 자체가 존재하지 않습니다.

픽스처의 precision 1.0 은 근거가 아닙니다. 미니 픽스처를 실제로 돌리면 후보 쌍이 1개(jeff.skilling ~ jskilling)이고, 골드셋의 음성 4쌍은 블로킹에서 걸러져 점수화 함수에 닿지도 않습니다. tp=1 · fp=0 의 산술일 뿐입니다. 픽스처가 확인하는 것은 배선이 이어졌는지이고, 실제 정밀도·재현율은 아직 측정되지 않았습니다 — Task 8 이 돌아야 나옵니다.

그리고 이 픽스처의 유일한 양성 쌍이 하필 가장 위험한 규칙의 사례입니다. {이름 첫 글자}{성} 형태를 0.90 으로 인정하는데(임계값 0.85), jsmith 는 John Smith 일 수도 Jane Smith 일 수도 있습니다. 규칙을 조이면 픽스처의 양성이 사라지므로, 조이기 전에 실데이터에서 이 규칙이 만드는 오탐 비율을 먼저 봐야 합니다. 오탐 경로 자체는 테스트로 못 박아두었습니다.

04 탐색 UI A 확정

노드를 그대로 다 그리면 아무것도 읽을 수 없는 털뭉치가 됩니다. 세 안을 놓고 A(앵커 + 점진 확장) 로 정했습니다.

++

A. 앵커 + 점진 확장  채택

인물을 검색해 하나 고르면 그 사람의 이웃만. 클릭할 때마다 한 겹씩 자람. 화면엔 사용자가 직접 펼친 것만.

TradingLegalExec

B. 조직 집약 → 드릴다운

부서 덩어리 10~20개로 시작. 선 굵기가 통신량. 전체 구도를 먼저 잡고 조직을 열어 개인으로.

C. 리스트 주도 + 그래프 보조

왼쪽 검색·필터·인물 랭킹이 주인공. 오른쪽 그래프는 근거를 확인하는 창.

A를 고른 이유: 엔티티 해소의 가치가 화면에 바로 드러나고, B는 조직 소속 추론이 선행돼야 해서 v1 범위가 커지며, A가 가장 작습니다(API 2개). 다만 "실무자가 찾을 사람을 이미 알고 시작한다"는 전제는 검증되지 않은 추측입니다. 실사용 피드백에서 뒤집히면 B를 재검토합니다.

A안 상세 — 세 가지 설계 결정

① 이웃을 무엇으로 자를 것인가 — 1-hop 이웃이 수백 명일 때 상위 15명만 보여주는데, 기준이 단순 메시지 수면 안 됩니다. Enron 에서 그렇게 하면 상위권이 메일링리스트·자동알림·뉴스레터로 채워집니다. 기본값은 양방향 가중치 min(A→B, B→A) — 한쪽으로만 쏟아지는 브로드캐스트는 자동으로 밀려나고 실제로 주고받은 관계가 올라옵니다.

② 확장 폭발 방지 — 노드 상한 150개. 초과하면 확장을 막고 접기를 안내합니다. 확장 취소(undo)를 제공하고, 자동으로 접지 않습니다 — 무엇이 사라졌는지 모르면 혼란스럽습니다.

③ 비교 장치를 UI에 내장 — 우측 상단 온톨로지 토글. ON 이면 Person 단위(별칭 통합), OFF 면 Actor 단위(이메일 주소 그대로). 같은 인물을 검색한 채로 토글하면 노드가 합쳐지고 엣지가 굵어지는 게 눈앞에서 일어납니다. 성능 향상을 말로 설명할 필요가 없어지고, 동시에 엔티티 해소가 틀렸을 때 바로 보이는 디버깅 도구가 됩니다.

API 두 개

GET /api/search?q=skilling
    → [{ person_id, display_name, alias_count, msg_count }, …]

GET /api/ego?person=P0042&limit=15&resolved=true
    → { nodes: [...], edges: [...] }

전체 그래프는 클라이언트로 보내지 않습니다. 각 Person 의 상위 이웃 리스트는 [5] project 단계에서 미리 계산해두므로 요청 시엔 조회만 합니다.

05 구현 진행 상황

태스크마다 새 에이전트가 구현하고, 별도 리뷰어가 diff 를 검증합니다. 리뷰가 통과할 때까지 다음 태스크로 넘어가지 않습니다.

#태스크상태비고
1프로젝트 뼈대 · 경로✅ 완료수정 1라운드
2maildir 파싱✅ 완료수정 1라운드 · Critical 2건
3정규화 · Actor 추출✅ 완료수정 1라운드 · Critical 1건 · Important 1건
4엔티티 해소✅ 완료블로킹 · Jaro-Winkler · 골드셋 평가
5온톨로지 · 추론✅ 완료union-find 폐포 · Person 노드 · RDFS 는 선택적
6property graph 투영✅ 완료weight = min · 일방향은 합계로 살린다
7API✅ 완료ego 서브그래프 · 노드 상한 · 모드별 프로필
8실데이터 파이프라인🔄 진행 중파이프라인 완주 · 골드셋 라벨링 전
9탐색 UI대기
10문서 갱신 · 배포대기

현재 테스트 126개 통과 (경로 3 · 인제스트 22 · 정규화 8 · 추출 3 · 해소 32 · 온톨로지 8 · 투영 14 · API 12 · CLI 8 · 코퍼스 픽스처 8 · 골드셋 후보 8) + JS 30개.

픽스처가 코퍼스를 대표하지 않으면 테스트는 아무것도 보장하지 않습니다. Task 1~7 이 전부 초록인 동안 파이프라인은 실데이터에서 0건을 넣고 0건을 해소했습니다. 미니 픽스처가 코퍼스와 두 번 멀어졌기 때문입니다 — 파일명에서 마침표를 지웠고, 코퍼스에 거의 없는 표시이름을 넣었습니다. 두 번 다 테스트를 통과시키려는 수정이었습니다. 지금은 코퍼스 성질을 재현하는 픽스처를 따로 두고 (마침표 파일 · 맨 주소 헤더 · 깨진 로컬파트 · 사본 · 함정 5종) 그 위에서 확인합니다. 전체 경위는 docs/reflexion/2026-09-08-process-reflexion.yaml 에 실패 12건 · 패턴 4개 · 조치 5개로 정리했습니다.

실데이터 (커스토디언 15명 · 240,577 파일)

세 번 돌렸습니다. 첫 실행은 해소가 0건이었고, 규칙을 고친 두 번째에 4,299쌍이 나왔고, 내용 해시 중복 제거를 넣은 세 번째가 현재 수치입니다.

항목1차현재
메시지240,577100,827 (사본 139,750 접음)
custody 행240,577240,577 (사본 전부 보존)
참여(participation)2,203,610843,040
actor36,28836,288
same_actor 쌍04,299
Person36,288 (= actor)32,840
person / actor 노드36,069 / 36,06932,643 / 36,069
가장 두꺼운 엣지5,4411,856
RDF 트리플2,626,470 · 180MB1,145,048 · 75MB
과병합 클러스터00
전체 소요약 27분약 13분

토글이 드디어 차이를 보여줍니다. person 노드 32,643 대 actor 노드 36,069 — 3,426개 차이입니다. 1차 실행에서는 두 그래프가 같은 크기여서, 이 제품의 핵심 주장이 실데이터에서 아무것도 보여주지 못했습니다. 해소가 무엇을 고쳐서 살아났는지는 아래 06 에 적었습니다.

아직 정밀도는 모릅니다. 4,299쌍이 나왔다는 것과 그것이 옳다는 것은 다른 문제입니다. 지금 믿을 수 있는 것은 과병합 클러스터가 0개이고 클러스터 최대 크기가 8명이라는 것 정도입니다.

골드셋 라벨링 후보 250쌍 — 층 설계를 측정으로 다시 잡았습니다

계획의 세 층 중 둘이 실데이터에서 비어 있었습니다.

  • "후보인데 임계값 미달"은 존재하지 않습니다. 로컬파트 규칙이 전부 0.85 이상을 돌려주므로 0 과 임계값 사이에 아무것도 없습니다 (미연결 표본 4,000쌍 전부 0점)
  • "블로킹 밖 동일-성"도 0입니다. 블로킹이 길이 4+ 토큰을 공유하는 사람 쌍을 전부 후보로 만듭니다 — 이 경로의 재현율 손실은 없습니다. 그것 자체가 측정 결과입니다
뽑는 법
정밀도150규칙별 층화 — 신뢰도 낮은 순으로 뽑으면 0.85 규칙 하나로만 채워집니다
재현율100이름 형태 · 토큰 공유 · 미연결, 로컬파트 유사도 내림차순

재현율 층의 상위가 규칙의 빈 곳을 그대로 보여줍니다philip ~ phillip, pete ~ peter, stace ~ stacey, edyeland ~ eydeland. 전부 같은 사람인데, 지금 규칙은 오타와 단축형을 전혀 다루지 않습니다. 이게 다음에 손댈 곳입니다.

행마다 판정 근거를 함께 싣습니다 — 로컬파트 유사도, 공통 상대방 수, 양쪽의 발신·수신 수, 표시이름. 주소만 주면 사람도 규칙과 같은 추측을 하기 때문입니다. adel.rizkalla ~ rizkalla.adel 은 공통 상대방이 74명이고, aagrawal@cba.ua.edu ~ aagrawal@haas.berkeley.edu 는 0명입니다.

06 리뷰가 찾은 것 기록

구현을 시작한 뒤 드러난 결함들. 대부분 계획 문서 자체의 오류였고, 구현자가 계획을 충실히 옮긴 결과 그대로 코드에 들어왔습니다. 각각 판정하고 계획을 고쳤습니다.

사전 점검에서 (구현 시작 전)

  • 픽스처에 표시이름이 없어 엔티티 해소 테스트가 통과할 수 없는 구조였음 → 메시지 1에만 표시이름을 주고 메시지 2는 맨 주소로 두어, localpart_name_match 규칙이 실제로 검증되게 함
  • 온톨로지 ON/OFF 비교 테스트가 무의미했음 — 이 픽스처에서 두 노드 집합이 동일해서 항상 통과 → 엣지 통신량 비교(3 vs 2)로 교체. 제품의 실제 주장을 테스트함
  • 엔티티 해소가 O(n²) — 커스토디언 15명이면 억 단위 쌍에 쌍마다 SQL. Task 8 이 끝나지 않을 구조 → 블로킹 추가
  • actor 모드에서 Person 프로필이 새어나옴 — 통합된 수치가 미통합 그래프에 표시되어, 토글이 존재하는 이유인 그 비교 자체를 오염시킴

Task 2 리뷰에서

  • Critical — import email 만으로는 email.message 가 바인딩되지 않음. pytest 가 부수효과로 끌어와서 테스트는 통과했지만, Task 8 의 python -m edisco.cli 는 로직 실행 전에 죽었을 것
  • Critical — 픽스처 파일명 끝 마침표. Windows 가 만들지도 조회하지도 못해 디스크에는 마침표 없이 저장되고, Windows 경로 해석이 불일치를 흡수해 통과. Linux CI 에서는 FileNotFoundError → 관례를 버림
  • Important — custody 가 조용히 버려짐. 같은 Message-ID 가 여러 커스토디언 사서함에 있는 건 Enron 의 기본값인데, 두 번째 사본이 기록 없이 사라졌음

eDiscovery 에서 custody 는 증거입니다. 누가 그 문서를 보관하고 있었는지는 사건에 관한 사실이고, 한 번 버리면 복원할 수 없습니다. message_custodian 테이블을 추가했습니다 — 지금 넣는 비용은 테이블 하나와 삽입 한 줄이고, Task 8 이 돈 뒤에 넣는 비용은 코퍼스 전체 재처리입니다.

Task 3 리뷰에서

  • Critical — name_key 가 중간 이름에서 두 형식이 어긋남. "Mary Jo Watkins"watkins, mary 이지만 "Watkins, Mary Jo"watkins, mary jo. 중간 이름은 헤더마다 있다 없다 하므로, 실제 코퍼스에서 같은 사람이 두 키로 갈라짐. Task 4 가 전적으로 이 불변식에 기댐 → 이름은 첫 토큰만 쓰도록 통일
  • Important — notenron.comenron.com 으로 접힘. endswith("enron.com") 에 점 경계 검사가 없었음. 주소가 곧 신원인 단계에서 외부인을 Enron 네임스페이스로 편입시키는 provenance 결함

Task 4 리뷰에서

  • 계획 문서에 남은 잔재를 구현자가 잡았습니다. resolve.py docstring 이 "클러스터로 닫는 건 ontology.py 의 OWL 추론이다"로 되어 있었습니다 — Task 5 를 union-find 로 재설계한 뒤 지우지 못한 문장입니다. 구현자가 브랜치 히스토리를 읽고 모순을 찾아 고쳤습니다
  • Efficiency — score_pair 가 participation 셀프 조인을 actor 당 두 번 돌았음. 채점 쌍당 쿼리 6개. 실데이터에서 후보 쌍은 수십만 개이므로 4개로 줄였습니다
  • 골드셋 1.0 의 의미를 재판정. 위 03 섹션의 콜아웃 참조 — 음성 쌍이 점수화 함수에 닿지 않는다는 걸 실제로 돌려서 확인했습니다

Task 8 실행에서 — 해소가 0건이던 이유와 그 뒤

파이프라인이 끝까지 돌았지만 해소가 아무것도 찾지 못했습니다. 성능 문제가 아니라 설계가 없는 데이터를 전제하고 있었습니다.

  • 이 코퍼스의 헤더는 맨 주소입니다. From: 의 0.2%, To: 의 1.1% 만 이름 <주소> 형태입니다. 2015년 배포본이 헤더를 정규화한 결과로 보입니다. 그래서 표시이름을 가진 actor 가 36,288명 중 774명(2.1%) 뿐입니다
  • 점수화 규칙 네 개가 모두 표시이름을 요구합니다. display_name_exact·name_similarity 는 양쪽, localpart_name_match 는 한쪽의 이름이 필요하고, shared_correspondents 조차 best_name ≥ 0.8 로 걸려 있습니다. 이름이 없으면 구조적으로 0건입니다 — 후보 400쌍을 표본 채점했더니 400쌍 전부 무규칙이었습니다
  • 픽스처가 규칙에 맞춰져 있었습니다. 사전 점검에서 "픽스처에 표시이름이 없어 해소 테스트가 통과할 수 없다"며 jeff.skilling 에 표시이름을 넣었습니다. 옳은 조치처럼 보였지만, 결과적으로 코퍼스에는 없는 조건을 픽스처에 만들어 넣은 것 이었습니다. 테스트가 초록이었던 이유가 거기 있습니다
  • 정작 잡아야 할 별칭은 눈에 보입니다. 예: vince.kaminski@enron.comvkaminski@aol.com 으로 메일을 보냅니다. 로컬파트끼리 비교하면 잡히지만, 지금 규칙은 로컬파트↔이름 만 봅니다

고친 결과: 주소 구조로 판단하는 규칙 5개를 넣고 (localpart_punctuation 0.95 · same_localpart_multi_token 0.93 · localpart_reversed 0.92 · initial_plus_last 0.88 · same_localpart_single_token 0.85) 4,299쌍이 나왔습니다. 가드가 먼저 필요했습니다 — 로컬파트가 깨진 actor 가 1,903명(5.2%) 있고, 가드 없이 돌리면 .aaron@enron.com 이 서로 다른 세 명의 Aaron 을 한 사람으로 묶습니다.

shared_correspondents 의 이름 게이트는 떼지 않았습니다. 떼자는 안을 검토했다가 측정 결과로 철회했습니다 — douglas.clifford ~ douglas.dunn, alison.keogh ~ alison.moss 가 병합됩니다. 최소 상대방 수를 걸어도 남습니다. 동료는 필연적으로 상대방을 공유하므로, 구조 신호만으로는 "같은 사람"과 "같은 팀"이 구별되지 않습니다.

중복 제거 — 전제가 이중으로 틀렸습니다

  • Message-ID 로는 한 번도 걸리지 않습니다. 2015년 배포본이 사본마다 ID 를 새로 발급했습니다 (JavaMail.evans@thyme). 240,577 파일에 고유 ID 가 정확히 240,577개입니다
  • 중복은 커스토디언 사이가 아니라 한 사람의 폴더 안에 있습니다. 계획은 "같은 메일이 여러 커스토디언 사서함에 있다"를 전제했지만, 실제로는 중복 메시지의 70.7%가 한 커스토디언 안 (all_documentsinbox/sent 에 같은 메일), 커스토디언 간은 3.1%뿐입니다

키는 내용 해시로 정했습니다 — blake2b(date, from, to, cc, subject, body). message_idsource_path 는 뺍니다. 사본마다 다르고, 그게 이 키가 필요한 이유입니다. 후보 네 개가 58.1~58.6% 로 수렴하므로 더 접는 쪽이 아니라 덜 틀리는 쪽을 골랐습니다 — 본문만으로 접으면 1,214건을 더 접는데, 그건 내용이 같지만 다른 시점에 보낸 메일까지 합치는 것입니다.

접되 custody 는 사본마다 남깁니다. message 100,827행에 custody 240,577행 — 원본 파일 수와 같습니다. 그래야 message_custodian 이 비로소 계획이 의도한 일을 합니다: "Organizational Changes 라는 이 한 통을 12명이 35개 사본으로 보관하고 있었다". 지금까지는 메시지당 정확히 1행이라 아무 일도 하지 않았습니다. 그리고 사본이 접히면서 가장 두꺼운 엣지가 5,441 → 1,856 로 떨어졌습니다 — 부풀림이 실재했다는 증거입니다.

Task 6·7 구현에서

  • Efficiency — build_profiles 가 Person 당 3쿼리. 합성 20,000 actor 규모에서 Person 18,000명에 5.4만 쿼리가 돌아 이 함수 하나가 모듈 전체보다 12배 느렸습니다 (14.81초 대 1.2초). 전체를 두 번 훑는 방식으로 바꿔 0.61초. Task 4 리뷰에서 잡은 "쌍마다 쿼리"와 같은 패턴이 계획에 그대로 남아 있었습니다
  • Critical — actor 모드가 통신량을 0 으로 비워두고 있었음. Person 프로필이 새어나오는 것을 막으려고 프로필을 아예 붙이지 않았는데, 그 결과 온톨로지 OFF 모드의 모든 노드가 sent/received 0 이 됐습니다. UI 는 노드 크기를 이 합으로 매기므로, 토글이 보여주려던 비교의 한쪽이 통째로 사라집니다. actor 자신의 수치만 담은 별도 프로필을 만들어 해결했습니다 — ON 은 skilling 이 별칭 2개에 발신 2통, OFF 는 발신 1통에 jskilling 이 발신 1통인 별도 노드입니다

계획 감사에서

Task 5 재설계가 Task 6·7·8 에 온전히 반영됐는지, 그리고 계획에 적힌 숫자가 맞는지 픽스처에서 재계산했습니다.

  • 숫자는 전부 맞았습니다 — actor 5 / Person 4 / sameActorAs 1쌍, Task 6 의 엣지 합계 3 · weight 1, Task 7 의 3 vs 2. 모두 실측 확인
  • 잘린 부정어. materialize_rdfs 설명이 "파이프라인 기본 경로에서는 부른다"로 끝나 Task 8 의 실제 코드와 모순이었습니다 (부르지 않습니다)
  • RDF 가 추론을 한다는 서술이 6곳에 남아 있었음 — 파일 구조 표, 두 모듈의 docstring, 커밋 메시지까지
  • 기대 테스트 개수 5곳이 계획 본문과 어긋남 (Task 2·3·4·5·7)

실측이 뒤집은 것

  • P1 — v1 규모 추정이 5배 틀림. 위 01 섹션 참조
  • P2 — owlrl 은 못 씁니다. 측정 완료. 범위를 좁히는 완화책으로는 구제되지 않았습니다 — 메시지 트리플을 다 빼고 actor 만 남겨도 마찬가지입니다:
    actors=2,000  msgs=0
      OWL-RL         4,217 ->    50,571      71.2s
      RDFS           4,217 ->     8,240       0.7s
      union-find     4,217 ->    44,217       0.3s
    세 가지가 드러났습니다. OWL-RL 은 2,000 actor 에 71초이고 초선형입니다. RDFS 는 폐포를 계산하지 않습니다 — 8,240개만 나온 게 그 증거이고, 대칭·이행은 OWL 구문이라 RDFS 프로파일에 없습니다. 그리고 완전 그래프를 심는 것 자체가 틀렸습니다 — union-find 가 20,000 actor 에서 400만 트리플을 만든 건 seed pair 가 사슬이 되어 2,001명 클러스터 하나로 병합된 결과입니다.

07 환경 제약

개발 머신이 기관 프록시 뒤에 있어 도구가 기본 설정으로 동작하지 않습니다.

증상대응
pipSSLCertVerificationError 로 죽음 --trusted-host pypi.org --trusted-host files.pythonhosted.org --trusted-host pypi.python.org
curl 이 exit 35 (SSL) 로 실패 PowerShell Invoke-WebRequest — Windows 인증서 저장소를 씀
파일명 끝 마침표를 pathlib 이 못 봄 열거는 os.walk, 열기는 확장 경로 (src/edisco/fsutil.py)

프록시가 TLS 를 가로채기 때문입니다. CI 로 옮기면 이 우회는 불필요해집니다. 매번 플래그를 붙이는 대신 pip.ini 에 기록하거나 기관 CA 인증서를 PIP_CERT 로 지정하는 편이 낫습니다.

마침표 문제는 픽스처 관례가 아니라 코퍼스 전체의 문제였습니다. Enron 코퍼스의 메일 파일은 전부 1. 처럼 마침표로 끝납니다 (allen-p 한 명만 봐도 628/628). Windows 는 경로를 정규화하면서 끝의 마침표를 떼기 때문에 Path(...).is_file() 이 False 가 되고, rglob("*") 로 훑으면 파일이 하나도 안 잡힙니다. 예외가 나지 않는다는 것이 이 문제의 핵심입니다 — 파이프라인이 0 건을 넣고 성공한 것처럼 끝납니다. 실제로 첫 실행에서 커스토디언 파일 수가 전부 0 으로 나왔고, 동점이 이름 역순으로 갈려 상위 15명이 zufferli-j, zipper-a, … 로 뽑혔습니다. 조용히 틀린 답을 내는 종류의 결함입니다.

08 남은 논의

닫힌 것

  • v1 커스토디언 수 = 15 — 파일 수 상위 15명. 재현 가능하고 사람 판단이 끼어들지 않습니다 (spec Q2). owlrl 을 버렸으므로 추론기 성능이 상한을 정하는 구조도 사라졌습니다
  • Task 5 추론 범위 — 질문 자체가 없어졌습니다. 폐포는 union-find 로 계산하고 RDFS 는 공유용 .ttl 을 만들 때만 선택적으로 씁니다
  • 골드셋 표집 — 250쌍, 세 갈래로 확정 (위 03 참조). 정밀도는 최악 구간에서 재므로 비관적이고, 재현율은 표본이 편향돼 낙관적입니다. blocked_out 은 블로킹 손실의 하한입니다 — 성이 서로 다르게 적힌 별칭은 동일-성 표본으로도 안 잡힙니다
  • 중복 제거 키 = 내용 해시blake2b(date, from, to, cc, subject, body). 사본 139,750건이 접혀 message 240,577 → 100,827, participation 2,203,610 → 843,040. custody 는 사본마다 보존합니다 (240,577행)
  • 해소 규칙 = 로컬파트 구조 — 이 코퍼스는 표시이름을 주지 않으므로 주소 구조가 본류입니다. 4,299쌍 · Person 32,840 · 과병합 0. shared_correspondents 의 이름 게이트는 유지합니다
  • 일방향 관계의 weight 0 — 실데이터에서 엣지의 91.4%가 여기 해당합니다 (147,246개 중 134,594개). min 은 유지합니다 (합계로 순위를 매기면 브로드캐스트가 상위를 차지함). 대신 API 가 합계를 같이 싣고, UI 는 굵기를 합계로 그리고 왕복이 아닌 관계는 점선으로 구별합니다. weight > 0 으로 필터하면 그래프의 대부분이 사라집니다 — 픽스처에서도 엣지 5개 중 4개가 0 입니다

열려 있는 것

  • Q1 (코드로 닫을 수 없음) — 실무자가 찾을 사람을 이미 알고 시작하는가? A안 선택의 전제이고, 실사용 피드백만이 답할 수 있습니다
  • 해소 규칙 재설계 — 이 코퍼스는 주소만 줍니다. 로컬파트끼리 비교하는 규칙(vkaminskivince.kaminski)과 shared_correspondents 의 이름 게이트 제거가 후보입니다. 규칙이 아무것도 못 잡는 상태에서 골드셋을 라벨링하는 건 의미가 없으므로 이것이 먼저입니다
  • 중복 제거 키 — 내용 58.2%가 중복입니다. (date, subject, from, to) 는 측정용으로 쓴 것이지 검증된 키가 아닙니다. 본문 해시를 넣을지, 날짜 정밀도를 어떻게 볼지 정해야 합니다
  • 실데이터 정확도 — 4,299쌍이 나왔지만 그것이 옳은지는 아직 모릅니다. 골드셋 250쌍을 손으로 라벨링해야 precision·recall 이 나오고, 그게 Task 8 의 남은 절반입니다. 첫 측정값이 기준선이 되고, 그 뒤로는 내리지 않습니다
  • initial_plus_last 의 오탐 비율 — 4,299쌍 중 2,108쌍이 이 규칙입니다. jsmith 가 John 인지 Jane 인지 주소만으로는 알 수 없으므로, 골드셋이 여기를 먼저 봐야 합니다. same_localpart_single_token(1,142쌍)도 기관이 다른 동명이인이 섞일 수 있습니다