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.
- temporal_belief_graph-0.1.0/.gitignore +9 -0
- temporal_belief_graph-0.1.0/.old/bayesian.py +210 -0
- temporal_belief_graph-0.1.0/.old/graph.py +147 -0
- temporal_belief_graph-0.1.0/.old/schema.py +169 -0
- temporal_belief_graph-0.1.0/.old/validator.py +149 -0
- temporal_belief_graph-0.1.0/PKG-INFO +216 -0
- temporal_belief_graph-0.1.0/README.md +191 -0
- temporal_belief_graph-0.1.0/pyproject.toml +49 -0
- temporal_belief_graph-0.1.0/tbg/__init__.py +18 -0
- temporal_belief_graph-0.1.0/tbg/bayesian.py +205 -0
- temporal_belief_graph-0.1.0/tbg/graph.py +165 -0
- temporal_belief_graph-0.1.0/tbg/schema.py +193 -0
- temporal_belief_graph-0.1.0/tbg/validator.py +139 -0
- temporal_belief_graph-0.1.0/tests/test_bayesian.py +187 -0
- temporal_belief_graph-0.1.0/tests/test_graph.py +113 -0
- temporal_belief_graph-0.1.0/tests/test_schema.py +92 -0
- temporal_belief_graph-0.1.0/tests/test_validator.py +139 -0
|
@@ -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
|
+
)
|