temporal-belief-graph 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ venv/
8
+ .venv/
9
+ *.egg
@@ -0,0 +1,210 @@
1
+ """
2
+ bayesian.py
3
+ -----------
4
+ BayesianUpdater : evidence 가 누적될 때마다 BeliefEdge.p_forward 를 갱신한다.
5
+
6
+ 핵심 수식
7
+ ---------
8
+ P(A→B | evidence) ∝ P(evidence | A→B) × P(A→B)
9
+
10
+ likelihood 는 evidence 의 방향성(supports_forward)과
11
+ 소스 신뢰도 가중치(weight)로 결정한다.
12
+
13
+ 업데이트 방식
14
+ -------------
15
+ 1. 단일 evidence : update_edge()
16
+ 2. 복수 evidence : update_edge_batch()
17
+ 3. 앙상블 통합 : ensemble_update()
18
+ - 여러 소스가 서로 다른 p_forward 를 주장할 때
19
+ 가중 평균으로 통합한 뒤 베이즈 업데이트 1회 적용
20
+ """
21
+
22
+ from __future__ import annotations
23
+ from dataclasses import dataclass
24
+ from .graph import BeliefGraph
25
+ from .schema import BeliefEdge
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Evidence
30
+ # ---------------------------------------------------------------------------
31
+
32
+ @dataclass
33
+ class Evidence:
34
+ """
35
+ 하나의 증거 단위.
36
+
37
+ Parameters
38
+ ----------
39
+ key : str
40
+ 증거 식별자. (예: "corpus/event_chain.json#kain_001")
41
+ supports_forward : bool
42
+ True → source → target 순서를 지지
43
+ False → target → source 순서를 지지
44
+ strength : float
45
+ 증거의 강도. (0.0, 1.0] 범위.
46
+ 1.0 = 완전히 확실한 증거, 0.1 = 매우 약한 힌트.
47
+ source : str
48
+ 증거 출처 키. PriorConfig.source_weights 와 매핑된다.
49
+ """
50
+
51
+ key: str
52
+ supports_forward: bool
53
+ strength: float = 0.5
54
+ source: str = ""
55
+
56
+ def __post_init__(self):
57
+ if not (0.0 < self.strength <= 1.0):
58
+ raise ValueError(
59
+ f"Evidence.strength={self.strength} 는 (0, 1] 범위여야 합니다."
60
+ )
61
+ if not self.key:
62
+ raise ValueError("Evidence.key 는 빈 문자열일 수 없습니다.")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # BayesianUpdater
67
+ # ---------------------------------------------------------------------------
68
+
69
+ class BayesianUpdater:
70
+ """
71
+ Parameters
72
+ ----------
73
+ graph : BeliefGraph
74
+ 업데이트 대상 그래프. 내부 엣지를 직접 수정한다.
75
+ clip : float
76
+ p_forward 의 최솟값/최댓값 클리핑. 기본 0.01.
77
+ 확률이 0 또는 1 로 수렴해 버리는 것을 방지한다.
78
+ """
79
+
80
+ def __init__(self, graph: BeliefGraph, clip: float = 0.01):
81
+ if not (0.0 < clip < 0.5):
82
+ raise ValueError(
83
+ f"clip={clip} 는 (0, 0.5) 범위여야 합니다."
84
+ )
85
+ self.graph = graph
86
+ self.clip = clip
87
+
88
+ # ------------------------------------------------------------------
89
+ # 단일 업데이트
90
+ # ------------------------------------------------------------------
91
+
92
+ def update_edge(
93
+ self,
94
+ source_id: str,
95
+ target_id: str,
96
+ evidence: Evidence,
97
+ ) -> BeliefEdge:
98
+ """
99
+ 단일 evidence 로 엣지 하나를 베이즈 업데이트한다.
100
+
101
+ Returns
102
+ -------
103
+ BeliefEdge
104
+ 업데이트된 엣지 (그래프 내 객체를 직접 수정 후 반환).
105
+ """
106
+ edge = self.graph.get_edge(source_id, target_id)
107
+ source_weight = self.graph._config.source_weights.get(evidence.source, 1.0)
108
+
109
+ # likelihood 계산
110
+ # strength=0.1 → lf=0.55, lb=0.45 (약한 신호)
111
+ # strength=1.0 → lf=1.0, lb=0.0 (강한 신호)
112
+ # source_weight 는 supports 방향 likelihood 에 스케일로 적용
113
+ base_lf = 0.5 + evidence.strength * 0.5
114
+ base_lb = 0.5 - evidence.strength * 0.5
115
+ if evidence.supports_forward:
116
+ lf = base_lf * source_weight
117
+ lb = base_lb
118
+ else:
119
+ lf = base_lb
120
+ lb = base_lf * source_weight
121
+
122
+ # 베이즈 업데이트
123
+ prior_f = edge.p_forward
124
+ prior_b = edge.p_backward
125
+
126
+ posterior_f_unnorm = lf * prior_f
127
+ posterior_b_unnorm = lb * prior_b
128
+ total = posterior_f_unnorm + posterior_b_unnorm
129
+
130
+ if total == 0:
131
+ return edge # 업데이트 불가 (수치 안정성)
132
+
133
+ new_p = posterior_f_unnorm / total
134
+ edge.p_forward = max(self.clip, min(1.0 - self.clip, new_p))
135
+
136
+ # evidence key 누적
137
+ if evidence.key not in edge.evidence_keys:
138
+ edge.evidence_keys.append(evidence.key)
139
+
140
+ return edge
141
+
142
+ # ------------------------------------------------------------------
143
+ # 배치 업데이트
144
+ # ------------------------------------------------------------------
145
+
146
+ def update_edge_batch(
147
+ self,
148
+ source_id: str,
149
+ target_id: str,
150
+ evidences: list[Evidence],
151
+ ) -> BeliefEdge:
152
+ """
153
+ 복수의 evidence 를 순서대로 적용한다.
154
+ 각 evidence 가 이전 posterior 를 다음 prior 로 사용한다.
155
+ """
156
+ for ev in evidences:
157
+ self.update_edge(source_id, target_id, ev)
158
+ return self.graph.get_edge(source_id, target_id)
159
+
160
+ # ------------------------------------------------------------------
161
+ # 앙상블 업데이트
162
+ # ------------------------------------------------------------------
163
+
164
+ def ensemble_update(
165
+ self,
166
+ source_id: str,
167
+ target_id: str,
168
+ claims: list[tuple[float, float]],
169
+ ) -> BeliefEdge:
170
+ """
171
+ 여러 소스의 p_forward 주장을 가중 평균으로 통합한 뒤
172
+ 베이즈 업데이트 1회 적용한다.
173
+
174
+ Parameters
175
+ ----------
176
+ claims : list[tuple[float, float]]
177
+ (p_forward_주장, 소스_가중치) 쌍의 목록.
178
+ 예: [(0.9, 1.5), (0.6, 0.8), (0.4, 1.0)]
179
+
180
+ Returns
181
+ -------
182
+ BeliefEdge
183
+ 업데이트된 엣지.
184
+ """
185
+ if not claims:
186
+ raise ValueError("claims 가 비어 있습니다.")
187
+
188
+ for p, w in claims:
189
+ if not (0.0 <= p <= 1.0):
190
+ raise ValueError(f"claim p_forward={p} 는 [0, 1] 범위여야 합니다.")
191
+ if w <= 0:
192
+ raise ValueError(f"claim weight={w} 는 0 보다 커야 합니다.")
193
+
194
+ total_weight = sum(w for _, w in claims)
195
+ ensemble_p = sum(p * w for p, w in claims) / total_weight
196
+
197
+ # 앙상블 결과를 단일 evidence 로 변환해 베이즈 업데이트
198
+ # ensemble_p=0.5 (중립) → strength≈0 (prior 유지)
199
+ # ensemble_p=1.0 → strength=1.0 (강한 순방향)
200
+ supports_forward = ensemble_p >= 0.5
201
+ strength = abs(ensemble_p - 0.5) * 2.0 # [0, 1]
202
+ strength = max(0.01, min(1.0, strength))
203
+
204
+ ev = Evidence(
205
+ key=f"ensemble:{source_id}→{target_id}",
206
+ supports_forward=supports_forward,
207
+ strength=strength,
208
+ source="ensemble",
209
+ )
210
+ return self.update_edge(source_id, target_id, ev)
@@ -0,0 +1,147 @@
1
+ """
2
+ graph.py
3
+ --------
4
+ BeliefGraph : 확률 엣지 기반 DAG 구조.
5
+
6
+ - EventNode 를 노드로, BeliefEdge 를 엣지로 관리한다.
7
+ - 엣지의 p_forward 는 이후 베이즈 업데이터(bayesian.py)가 갱신한다.
8
+ - 이 파일은 구조(추가/조회/삭제)만 담당한다.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ from typing import Optional
13
+ from .schema import EventNode, BeliefEdge, PriorConfig
14
+
15
+
16
+ class BeliefGraph:
17
+ """
18
+ 확률 엣지 기반 방향 그래프.
19
+
20
+ Parameters
21
+ ----------
22
+ prior_config : PriorConfig
23
+ 초기 조건. 생략하면 default PriorConfig() 사용.
24
+ """
25
+
26
+ def __init__(self, prior_config: Optional[PriorConfig] = None):
27
+ self._config: PriorConfig = prior_config or PriorConfig()
28
+ self._nodes: dict[str, EventNode] = {}
29
+ # (source_id, target_id) → BeliefEdge
30
+ self._edges: dict[tuple[str, str], BeliefEdge] = {}
31
+
32
+ # ------------------------------------------------------------------
33
+ # 노드
34
+ # ------------------------------------------------------------------
35
+
36
+ def add_node(self, node: EventNode) -> None:
37
+ """노드를 추가한다. 같은 id 가 이미 있으면 덮어쓴다."""
38
+ self._nodes[node.id] = node
39
+
40
+ def get_node(self, node_id: str) -> EventNode:
41
+ if node_id not in self._nodes:
42
+ raise KeyError(f"노드 '{node_id}' 가 그래프에 없습니다.")
43
+ return self._nodes[node_id]
44
+
45
+ def remove_node(self, node_id: str) -> None:
46
+ """노드와 해당 노드에 연결된 모든 엣지를 함께 제거한다."""
47
+ if node_id not in self._nodes:
48
+ raise KeyError(f"노드 '{node_id}' 가 그래프에 없습니다.")
49
+ del self._nodes[node_id]
50
+ to_remove = [
51
+ key for key in self._edges
52
+ if key[0] == node_id or key[1] == node_id
53
+ ]
54
+ for key in to_remove:
55
+ del self._edges[key]
56
+
57
+ @property
58
+ def nodes(self) -> list[EventNode]:
59
+ return list(self._nodes.values())
60
+
61
+ # ------------------------------------------------------------------
62
+ # 엣지
63
+ # ------------------------------------------------------------------
64
+
65
+ def add_edge(self, edge: BeliefEdge) -> None:
66
+ """
67
+ 엣지를 추가한다.
68
+ - source/target 노드가 그래프에 없으면 KeyError.
69
+ - 같은 (source, target) 쌍이 이미 있으면 덮어쓴다.
70
+ """
71
+ if edge.source_id not in self._nodes:
72
+ raise KeyError(f"source 노드 '{edge.source_id}' 가 그래프에 없습니다.")
73
+ if edge.target_id not in self._nodes:
74
+ raise KeyError(f"target 노드 '{edge.target_id}' 가 그래프에 없습니다.")
75
+ self._edges[(edge.source_id, edge.target_id)] = edge
76
+
77
+ def get_edge(self, source_id: str, target_id: str) -> BeliefEdge:
78
+ key = (source_id, target_id)
79
+ if key not in self._edges:
80
+ raise KeyError(f"엣지 '{source_id} → {target_id}' 가 그래프에 없습니다.")
81
+ return self._edges[key]
82
+
83
+ def remove_edge(self, source_id: str, target_id: str) -> None:
84
+ key = (source_id, target_id)
85
+ if key not in self._edges:
86
+ raise KeyError(f"엣지 '{source_id} → {target_id}' 가 그래프에 없습니다.")
87
+ del self._edges[key]
88
+
89
+ @property
90
+ def edges(self) -> list[BeliefEdge]:
91
+ return list(self._edges.values())
92
+
93
+ # ------------------------------------------------------------------
94
+ # 조회 유틸
95
+ # ------------------------------------------------------------------
96
+
97
+ def successors(self, node_id: str) -> list[EventNode]:
98
+ """node_id 에서 출발하는 엣지의 target 노드 목록."""
99
+ return [
100
+ self._nodes[t]
101
+ for (s, t) in self._edges
102
+ if s == node_id and t in self._nodes
103
+ ]
104
+
105
+ def predecessors(self, node_id: str) -> list[EventNode]:
106
+ """node_id 로 들어오는 엣지의 source 노드 목록."""
107
+ return [
108
+ self._nodes[s]
109
+ for (s, t) in self._edges
110
+ if t == node_id and s in self._nodes
111
+ ]
112
+
113
+ def uncertain_edges(self) -> list[BeliefEdge]:
114
+ """p_forward 가 [0.4, 0.6] 인 불확실 엣지 목록."""
115
+ return [e for e in self._edges.values() if e.is_uncertain]
116
+
117
+ # ------------------------------------------------------------------
118
+ # 초기화 헬퍼
119
+ # ------------------------------------------------------------------
120
+
121
+ def init_uniform(self, source_id: str, target_id: str) -> BeliefEdge:
122
+ """
123
+ 두 노드 사이에 uniform prior(p=0.5) 엣지를 생성하고 추가한다.
124
+ 이미 엣지가 있으면 기존 엣지를 반환한다.
125
+ """
126
+ key = (source_id, target_id)
127
+ if key in self._edges:
128
+ return self._edges[key]
129
+ edge = BeliefEdge(
130
+ source_id=source_id,
131
+ target_id=target_id,
132
+ p_forward=self._config.default_p,
133
+ )
134
+ self.add_edge(edge)
135
+ return edge
136
+
137
+ # ------------------------------------------------------------------
138
+ # 표현
139
+ # ------------------------------------------------------------------
140
+
141
+ def __repr__(self) -> str:
142
+ return (
143
+ f"BeliefGraph("
144
+ f"nodes={len(self._nodes)}, "
145
+ f"edges={len(self._edges)}, "
146
+ f"uncertain={len(self.uncertain_edges())})"
147
+ )
@@ -0,0 +1,169 @@
1
+ """
2
+ schema.py
3
+ ---------
4
+ tbg의 기본 데이터 스키마.
5
+
6
+ EventNode : 이벤트 하나를 표현하는 노드
7
+ BeliefEdge : A → B 순서 관계의 확률을 담는 엣지
8
+ PriorConfig : 사람이 처음 한 번만 설정하는 초기 조건
9
+ """
10
+
11
+ from __future__ import annotations
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # EventNode
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @dataclass
21
+ class EventNode:
22
+ """
23
+ 스토리/세계관에서 하나의 사건(이벤트)을 표현한다.
24
+
25
+ Parameters
26
+ ----------
27
+ id : str
28
+ 고유 식별자. 영문 소문자 + 언더스코어 권장. (예: "kain_incident")
29
+ label : str
30
+ 사람이 읽을 이름. (예: "카인 사건")
31
+ era : Optional[str]
32
+ 이 노드가 속한 시대/구간 이름.
33
+ None 이면 era 미지정 상태 → validator 가 경고.
34
+ description : str
35
+ 사건에 대한 자유 텍스트 설명.
36
+ sources : list[str]
37
+ 이 사건을 뒷받침하는 소스 키 목록.
38
+ (예: ["corpus/event_chain.json#001"])
39
+ """
40
+
41
+ id: str
42
+ label: str
43
+ era: Optional[str] = None
44
+ description: str = ""
45
+ sources: list[str] = field(default_factory=list)
46
+
47
+ def __post_init__(self):
48
+ if not self.id:
49
+ raise ValueError("EventNode.id 는 빈 문자열일 수 없습니다.")
50
+ if not self.label:
51
+ raise ValueError("EventNode.label 은 빈 문자열일 수 없습니다.")
52
+ # pseudo era 금지 목록 (validator 에서도 재검사, 여기서는 즉시 차단)
53
+ _PSEUDO = {"현재", "과거", "미래", "언젠가", "나중에", "예전에"}
54
+ if self.era in _PSEUDO:
55
+ raise ValueError(
56
+ f"EventNode.era='{self.era}' 는 pseudo era 입니다. "
57
+ "구체적인 사건명/시대명을 사용하세요."
58
+ )
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # BeliefEdge
63
+ # ---------------------------------------------------------------------------
64
+
65
+ @dataclass
66
+ class BeliefEdge:
67
+ """
68
+ 두 EventNode 사이의 순서 관계를 확률로 표현하는 엣지.
69
+
70
+ 'source → target' 순서일 확률을 p_forward 로 저장한다.
71
+ p_forward = 0.5 → 순서를 모른다 (uniform prior)
72
+ p_forward = 1.0 → source 가 반드시 먼저
73
+ p_forward = 0.0 → target 이 반드시 먼저
74
+
75
+ Parameters
76
+ ----------
77
+ source_id : str
78
+ 선행 이벤트 후보 노드 id.
79
+ target_id : str
80
+ 후행 이벤트 후보 노드 id.
81
+ p_forward : float
82
+ P(source → target). 0.0 이상 1.0 이하.
83
+ evidence_keys : list[str]
84
+ 이 엣지의 확률을 뒷받침하는 증거 키 목록.
85
+ weight : float
86
+ 앙상블에서 이 엣지 소스의 신뢰도 가중치. 기본 1.0.
87
+ """
88
+
89
+ source_id: str
90
+ target_id: str
91
+ p_forward: float = 0.5
92
+ evidence_keys: list[str] = field(default_factory=list)
93
+ weight: float = 1.0
94
+
95
+ def __post_init__(self):
96
+ if not (0.0 <= self.p_forward <= 1.0):
97
+ raise ValueError(
98
+ f"BeliefEdge.p_forward={self.p_forward} 는 [0, 1] 범위여야 합니다."
99
+ )
100
+ if self.weight <= 0:
101
+ raise ValueError(
102
+ f"BeliefEdge.weight={self.weight} 는 0 보다 커야 합니다."
103
+ )
104
+ if self.source_id == self.target_id:
105
+ raise ValueError(
106
+ "BeliefEdge 의 source_id 와 target_id 는 같을 수 없습니다. "
107
+ "(self-loop 금지)"
108
+ )
109
+
110
+ @property
111
+ def p_backward(self) -> float:
112
+ """P(target → source) = 1 - p_forward"""
113
+ return 1.0 - self.p_forward
114
+
115
+ @property
116
+ def is_uncertain(self) -> bool:
117
+ """p_forward 가 [0.4, 0.6] 구간이면 '불확실' 상태로 간주."""
118
+ return 0.4 <= self.p_forward <= 0.6
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # PriorConfig
123
+ # ---------------------------------------------------------------------------
124
+
125
+ @dataclass
126
+ class PriorConfig:
127
+ """
128
+ 사람이 처음 한 번만 설정하는 초기 조건 스키마.
129
+
130
+ 이후 evidence 가 누적되면 BeliefEdge.p_forward 가 자동 갱신되므로
131
+ PriorConfig 는 '시작점'만 정의한다.
132
+
133
+ Parameters
134
+ ----------
135
+ default_p : float
136
+ 아무 정보 없을 때 기본 prior. 보통 0.5 (uniform).
137
+ source_weights : dict[str, float]
138
+ 소스별 초기 신뢰도 가중치.
139
+ (예: {"corpus/event_chain.json": 1.5, "fan_wiki": 0.7})
140
+ pseudo_era_list : list[str]
141
+ 추가로 막을 pseudo era 키워드. 기본 목록에 합산된다.
142
+ """
143
+
144
+ default_p: float = 0.5
145
+ source_weights: dict[str, float] = field(default_factory=dict)
146
+ pseudo_era_list: list[str] = field(default_factory=list)
147
+
148
+ # 기본 pseudo era 목록 (EventNode.__post_init__ 과 동일하게 유지)
149
+ _BASE_PSEUDO: list[str] = field(
150
+ default_factory=lambda: ["현재", "과거", "미래", "언젠가", "나중에", "예전에"],
151
+ init=False,
152
+ repr=False,
153
+ )
154
+
155
+ def __post_init__(self):
156
+ if not (0.0 < self.default_p < 1.0):
157
+ raise ValueError(
158
+ f"PriorConfig.default_p={self.default_p} 는 (0, 1) 열린 구간이어야 합니다."
159
+ )
160
+ for src, w in self.source_weights.items():
161
+ if w <= 0:
162
+ raise ValueError(
163
+ f"source_weights['{src}']={w} 는 0 보다 커야 합니다."
164
+ )
165
+
166
+ @property
167
+ def all_pseudo_eras(self) -> list[str]:
168
+ """기본 목록 + 사용자 추가 목록을 합친 전체 pseudo era 목록."""
169
+ return list(set(self._BASE_PSEUDO + self.pseudo_era_list))
@@ -0,0 +1,149 @@
1
+ """
2
+ validator.py
3
+ ------------
4
+ Validator : BeliefGraph 의 유효성을 검사한다.
5
+
6
+ 검사 항목
7
+ 1. pseudo era 탐지 - EventNode.era 가 금지 키워드면 오류
8
+ 2. era 미지정 탐지 - EventNode.era 가 None 이면 경고
9
+ 3. 사이클 탐지 - p_forward > threshold 인 엣지만 확정 엣지로 보고
10
+ DFS 로 사이클 존재 여부를 확인한다
11
+ 4. 고립 노드 탐지 - 엣지가 하나도 없는 노드 목록 반환
12
+
13
+ ValidationResult 로 결과를 돌려주며 예외를 던지지 않는다.
14
+ 예외가 필요한 경우 .raise_if_errors() 를 호출한다.
15
+ """
16
+
17
+ from __future__ import annotations
18
+ from dataclasses import dataclass, field
19
+ from .graph import BeliefGraph
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # 결과 컨테이너
24
+ # ---------------------------------------------------------------------------
25
+
26
+ @dataclass
27
+ class ValidationResult:
28
+ errors: list[str] = field(default_factory=list)
29
+ warnings: list[str] = field(default_factory=list)
30
+
31
+ @property
32
+ def is_valid(self) -> bool:
33
+ return len(self.errors) == 0
34
+
35
+ def raise_if_errors(self) -> None:
36
+ if self.errors:
37
+ msg = "\n".join(self.errors)
38
+ raise ValueError(f"BeliefGraph 유효성 오류:\n{msg}")
39
+
40
+ def __repr__(self) -> str:
41
+ return (
42
+ f"ValidationResult("
43
+ f"errors={len(self.errors)}, "
44
+ f"warnings={len(self.warnings)}, "
45
+ f"valid={self.is_valid})"
46
+ )
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Validator
51
+ # ---------------------------------------------------------------------------
52
+
53
+ class Validator:
54
+ """
55
+ Parameters
56
+ ----------
57
+ cycle_threshold : float
58
+ 이 값보다 p_forward 가 크면 '확정 엣지'로 간주하고 사이클을 탐지한다.
59
+ 기본 0.5 (불확실 엣지는 사이클 탐지에서 제외).
60
+ """
61
+
62
+ def __init__(self, cycle_threshold: float = 0.5):
63
+ if not (0.0 < cycle_threshold < 1.0):
64
+ raise ValueError(
65
+ f"cycle_threshold={cycle_threshold} 는 (0, 1) 범위여야 합니다."
66
+ )
67
+ self.cycle_threshold = cycle_threshold
68
+
69
+ # ------------------------------------------------------------------
70
+ # 퍼블릭 API
71
+ # ------------------------------------------------------------------
72
+
73
+ def validate(self, graph: BeliefGraph) -> ValidationResult:
74
+ """그래프 전체를 검사하고 ValidationResult 를 반환한다."""
75
+ result = ValidationResult()
76
+ self._check_pseudo_era(graph, result)
77
+ self._check_missing_era(graph, result)
78
+ self._check_cycles(graph, result)
79
+ self._check_isolated_nodes(graph, result)
80
+ return result
81
+
82
+ # ------------------------------------------------------------------
83
+ # 개별 검사
84
+ # ------------------------------------------------------------------
85
+
86
+ def _check_pseudo_era(self, graph: BeliefGraph, result: ValidationResult) -> None:
87
+ pseudo_set = set(graph._config.all_pseudo_eras)
88
+ for node in graph.nodes:
89
+ if node.era in pseudo_set:
90
+ result.errors.append(
91
+ f"[pseudo_era] 노드 '{node.id}' 의 era='{node.era}' 는 "
92
+ "금지된 pseudo era 입니다."
93
+ )
94
+
95
+ def _check_missing_era(self, graph: BeliefGraph, result: ValidationResult) -> None:
96
+ for node in graph.nodes:
97
+ if node.era is None:
98
+ result.warnings.append(
99
+ f"[missing_era] 노드 '{node.id}' 에 era 가 설정되지 않았습니다."
100
+ )
101
+
102
+ def _check_cycles(self, graph: BeliefGraph, result: ValidationResult) -> None:
103
+ """
104
+ p_forward > cycle_threshold 인 엣지만 '확정 방향'으로 보고
105
+ DFS 로 사이클을 탐지한다.
106
+ """
107
+ # 인접 리스트 구성
108
+ adj: dict[str, list[str]] = {node.id: [] for node in graph.nodes}
109
+ for edge in graph.edges:
110
+ if edge.p_forward > self.cycle_threshold:
111
+ adj[edge.source_id].append(edge.target_id)
112
+
113
+ # DFS 상태: 0=미방문, 1=방문중, 2=완료
114
+ state: dict[str, int] = {node_id: 0 for node_id in adj}
115
+ cycle_path: list[str] = []
116
+
117
+ def dfs(node_id: str) -> bool:
118
+ state[node_id] = 1
119
+ cycle_path.append(node_id)
120
+ for neighbor in adj[node_id]:
121
+ if state[neighbor] == 1:
122
+ # 사이클 발견 - 경로 추출
123
+ idx = cycle_path.index(neighbor)
124
+ cycle = cycle_path[idx:] + [neighbor]
125
+ result.errors.append(
126
+ f"[cycle] 사이클 탐지: {' → '.join(cycle)}"
127
+ )
128
+ return True
129
+ if state[neighbor] == 0:
130
+ if dfs(neighbor):
131
+ return True
132
+ cycle_path.pop()
133
+ state[node_id] = 2
134
+ return False
135
+
136
+ for node_id in adj:
137
+ if state[node_id] == 0:
138
+ dfs(node_id)
139
+
140
+ def _check_isolated_nodes(self, graph: BeliefGraph, result: ValidationResult) -> None:
141
+ connected = set()
142
+ for edge in graph.edges:
143
+ connected.add(edge.source_id)
144
+ connected.add(edge.target_id)
145
+ for node in graph.nodes:
146
+ if node.id not in connected:
147
+ result.warnings.append(
148
+ f"[isolated] 노드 '{node.id}' 는 연결된 엣지가 없습니다."
149
+ )