Skip to content

fix(graph): add_edge 가 없는 endpoint 에 타입을 지어내 dangling 엣지를 만든다 - #11

Open
epruseal wants to merge 1 commit into
AlexAI-MCP:mainfrom
epruseal:fix/add-edge-endpoint-guard
Open

fix(graph): add_edge 가 없는 endpoint 에 타입을 지어내 dangling 엣지를 만든다#11
epruseal wants to merge 1 commit into
AlexAI-MCP:mainfrom
epruseal:fix/add-edge-endpoint-guard

Conversation

@epruseal

@epruseal epruseal commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

현상

존재하지 않는 endpoint 로 add_edge 를 호출하면 성공을 보고하고, 잘못된 타입의 행을 씁니다.

b.add_node('subject','User','u1', {...})
r = b.add_edge('subject','u1','owns','resource','doc-late')   # doc-late 는 아직 없음

  stores : {'neo4j': 'ok', 'postgres': 'ok', 'mongodb': 'audited'}   <- ok 라고 보고
  저장된 엣지: [('User','u1','owns','Project','doc-late')]           <- Project 를 지어냄

뒤늦게 진짜 노드를 만들고 재실행해도 교정되지 않고 중복 행이 남습니다.

b.add_node('resource','Document','doc-late', {...})
b.add_edge('subject','u1','owns','resource','doc-late')

  저장: [('User','u1','owns','Document','doc-late'),      <- 정상 행
         ('User','u1','owns','Project','doc-late')]       <- 유령 행이 그대로
  dangling: 1

원인

opencrab/ontology/builder.py:206-208 (#4 에서 도입된 타입 해석부):

lookup = getattr(self._neo4j, "lookup_node_type", None)
from_type = (lookup(from_id) if lookup else None) or _space_to_default_type(from_space)
to_type   = (lookup(to_id)   if lookup else None) or _space_to_default_type(to_space)

lookupNone 을 돌려주는 것은 "그 노드가 없다" 는 뜻인데, 폴백이 그걸 "타입을 모른다"로 취급해 _space_to_default_type() 으로 space 의 첫 선언 타입을 지어냅니다.

subject -> User      resource -> Project     evidence -> TextUnit
concept -> Entity    claim    -> Claim       ...

여기서 백엔드 비대칭이 겹칩니다.

백엔드 upsert_edge 동작
Neo4jStore MATCH 기반 - endpoint 가 없으면 아무것도 쓰지 않고 False 반환
LocalGraphStore endpoint 검사 없는 INSERT ... ON CONFLICT DO UPDATE - 그대로 씀

그리고 graph_edges 의 PK 가 (from_type, from_id, relation, to_type, to_id)타입이 키에 포함됩니다. 지어낸 타입이 실제 타입과 다르면 재적재로도 그 행을 만날 수 없어 영구 잔존합니다.

실제 타입이 우연히 space 기본값과 같으면(예: evidence 의 실제 TextUnit) 자기교정됩니다. 다를 때만 영구 오염입니다 - 위 재현이 그 경우입니다.

수정

add_edge 가 양쪽 endpoint 를 확인하고, 하나라도 없으면 Neo4j 가 이미 쓰던 no match 계약으로 거부합니다. 예외를 던지지 않으므로 기존 호출자 계약이 유지됩니다.

  • 거부 사유를 missing_nodes 로 반환하고 logger.warning 으로 남깁니다
  • PostgreSQL 레지스트리 기록도 함께 건너뜁니다(그래프에 없는 엣지를 레지스트리가 갖지 않도록)
  • 스토어가 available=False 면 가드는 작동하지 않습니다 - "노드 없음"과 "스토어 다운"을 구분할 수 없고, 어차피 그래프에 쓰지 않아 오염이 불가능하기 때문입니다. 이 경우 기존 space 기본값 동작을 그대로 둡니다

결과 (실측)

같은 시나리오, d34352c 대비:

[수정 전] stores.neo4j='ok'
          저장: [('User','u1','owns','Project','doc-late')]
          노드 생성 후 재실행: 2행 (정상 + 유령), dangling 1

[수정 후] stores={'neo4j': 'no match (missing node: resource/doc-late)',
                  'postgres': 'skipped (missing node)', 'mongodb': 'audited'}
          missing_nodes=['resource/doc-late']
          저장: []
          노드 생성 후 재실행: [('User','u1','owns','Document','doc-late')] (1행), dangling 0

테스트 5종 추가(양쪽 endpoint 각각 결손, 양쪽 동시 결손, 정상 타입 보존, unavailable 시 가드 비작동). d34352c 트리에 이 테스트를 얹으면 3건 실패해 결함을 실제로 잡는 것을 확인했습니다.

패치 후 : 5 passed
패치 전 : 3 failed, 2 passed
전체    : 136 passed, 3 skipped   (회귀 없음)

배경

로컬 모드 배포에서 실제로 이 결함을 만났습니다. 한 배포의 그래프에 11,833건
dangling 엣지가 이 경로로 생성돼 있었고, 전수 조사 결과 전부 from 쪽 결손이며
from_type 이 예외 없이 User(= subject space 의 첫 선언 타입)였습니다. 원인
노드 4개가 다른 이유로 적재 중 누락돼 있었고, 그 노드를 복구하니 해소됐습니다
(실제 타입이 User 였으므로 위 "수렴하는 경우"에 해당).

타입이 실제와 다르게 박히는(= 위 "수렴하지 않는") 사례도 관측했습니다. 다만 이
PR 초안에 적었던 건수는 재확인 과정에서 맞지 않아 철회했습니다. 결함 서명 자체는
남아 있습니다 - 예를 들어 어떤 스냅샷에는 Evidence -[supports]-> Claim 엣지가
83건 있는데 해당 노드의 실제 타입은 CollectionCompleteness 입니다(claim space 의
첫 선언 타입이 Claim).

참고: 같은 폴백을 쓰는 형제 경로

이 PR 범위는 OntologyBuilder.add_edge 입니다. apps/api/main.py
_resolve_node_types(:414)도 존재 검사 없이 _space_to_default_type 폴백만
하고, 그 타입이 :677ctx.graph.upsert_edge 로 넘어갑니다. 리뷰어가 함께 보실 만한 지점이라
적어 둡니다(범위를 넓히길 원하시면 이 PR 에 포함하겠습니다).

존재하지 않는 endpoint 로 add_edge 를 호출하면 성공을 보고하고 잘못된 타입의
행을 쓴다. 실제 노드의 타입이 space 기본값과 다르면 뒤늦게 진짜 노드를 만들고
재실행해도 교정되지 않고 중복으로 남는다.

builder.py 의 타입 해석부(#4 도입)에서 lookup 이 None 을 돌려주는 것은
"그 노드가 없다"는 뜻인데, 폴백이 이를 "타입을 모른다"로 취급해
_space_to_default_type() 으로 space 의 첫 선언 타입을 지어낸다
(subject -> User, resource -> Project, evidence -> TextUnit ...).

여기에 백엔드 비대칭이 겹친다. Neo4jStore.upsert_edge 는 MATCH 기반이라
endpoint 가 없으면 아무것도 쓰지 않고 False 를 돌려주지만, LocalGraphStore 는
endpoint 검사가 없는 INSERT ... ON CONFLICT DO UPDATE 라 그대로 쓴다.
graph_edges 의 PK 가 (from_type, from_id, relation, to_type, to_id) 로 타입을
포함하므로, 지어낸 타입이 실제와 다르면 재적재 시 정상 행이 따로 삽입되고
날조된 행은 dangling 으로 남는다. 실제 타입이 우연히 space 기본값과 같으면
재적재로 수렴한다.

수정: 양쪽 endpoint 를 확인하고 하나라도 없으면 Neo4j 가 이미 쓰던 "no match"
계약으로 거부한다. 예외를 던지지 않으므로 기존 호출자 계약이 유지된다.
거부 사유는 missing_nodes 로 반환하고 PostgreSQL 레지스트리 기록도 함께
건너뛴다(그래프에 없는 엣지를 레지스트리가 갖지 않도록).

스토어가 available=False 면 가드는 작동하지 않는다 - "노드 없음"과 "스토어
다운"을 구분할 수 없고, 어차피 그래프에 쓰지 않아 오염이 불가능하기 때문이다.
이 경우 기존 space 기본값 동작을 그대로 둔다.

실측 (d34352c 대비). resource 의 기본값은 Project 이고 실제 노드는 Document 인
경우:

  [수정 전] stores.neo4j='ok'
            저장: [('User','u1','owns','Project','doc-late')]
            노드 생성 후 재실행: 2행(정상 + 날조), dangling 1
  [수정 후] stores.neo4j='no match (missing node: resource/doc-late)'
            stores.postgres='skipped (missing node)'
            missing_nodes=['resource/doc-late']
            저장: []
            노드 생성 후 재실행: 1행, dangling 0

대조군: 실제 타입이 기본값과 같은 경우(evidence 의 TextUnit)는 수정 전에도
재실행으로 1행 dangling 0 으로 수렴한다.

테스트 5종 추가(양쪽 endpoint 각각 결손, 양쪽 동시 결손, 정상 타입 보존,
unavailable 시 가드 비작동). d34352c 트리에 이 테스트를 얹으면 3건 실패해
결함을 실제로 잡는 것을 확인했다.

  패치 후 5 passed / 패치 전 3 failed 2 passed
  전체 스위트 136 passed 3 skipped (base 131 + 신규 5)
@epruseal
epruseal force-pushed the fix/add-edge-endpoint-guard branch from b668ad8 to 792cf21 Compare July 30, 2026 01:54
@epruseal epruseal changed the title fix(graph): add_edge 가 없는 endpoint 에 타입을 지어내 영구 dangling 엣지를 만든다 fix(graph): add_edge 가 없는 endpoint 에 타입을 지어내 dangling 엣지를 만든다 Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant