File size: 15,046 Bytes
3e363e2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 | """Typed contracts shared by the GRM model, search, verifier, and API."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
import torch
from torch import Tensor
class ReasoningStatus(StrEnum):
SUPPORTED = "supported"
UNSUPPORTED = "unsupported"
CONTRADICTED = "contradicted"
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
ERROR = "error"
@dataclass(frozen=True)
class GraphTensors:
"""A validated, batch-agnostic graph tensor contract.
``edge_index`` is shaped ``[2, E]`` and stores source/target node indices.
IDs are indices into the graph tensors, never user-facing entity IDs.
Two kinds of type id are carried, and the distinction is load-bearing:
``node_type_ids`` / ``edge_type_ids``
*Local* indices -- positions within this graph's sorted type and relation
lists. They exist for bookkeeping: pooling per-edge states into a
per-graph relation bank, and mapping a bank row back to a relation name.
``node_type_hashes`` / ``edge_type_hashes``
*Global, graph-independent* identities, used for the learned embeddings.
Using the local index for the embedding lookup was a significant bug. Local
indices come from the alphabetical rank of a name *within one graph*, so a
graph that simply lacked one relation shifted every later index: ``owns``
could be embedding row 1 in one graph and row 0 in the next. The relation and
type embedding tables could therefore not learn a stable representation of any
relation, and renaming labels reshuffled everything, which is what collapsed
the unseen-ontology holdout.
"""
node_ids: Tensor
node_type_ids: Tensor
node_features: Tensor
edge_index: Tensor
edge_type_ids: Tensor
edge_features: Tensor
graph_id: Tensor
#: Stable global type identities for the learned embeddings. Default to the
#: local ids so older callers keep working, with the drift they always had.
node_type_hashes: Tensor | None = None
edge_type_hashes: Tensor | None = None
def __post_init__(self) -> None:
_require_rank(self.node_ids, 1, "node_ids")
_require_rank(self.node_type_ids, 1, "node_type_ids")
_require_rank(self.node_features, 2, "node_features")
_require_rank(self.edge_index, 2, "edge_index")
_require_rank(self.edge_type_ids, 1, "edge_type_ids")
_require_rank(self.edge_features, 2, "edge_features")
_require_rank(self.graph_id, 1, "graph_id")
if self.edge_index.shape[0] != 2:
raise ValueError(
f"edge_index must have shape [2, E], got {tuple(self.edge_index.shape)}"
)
n = self.node_ids.shape[0]
e = self.edge_index.shape[1]
if self.node_type_ids.shape[0] != n or self.node_features.shape[0] != n:
raise ValueError(
"node_ids, node_type_ids, and node_features must have the same first dimension"
)
if self.edge_type_ids.shape[0] != e or self.edge_features.shape[0] != e:
raise ValueError(
"edge_index, edge_type_ids, and edge_features must have the same edge dimension"
)
if self.graph_id.shape[0] != n:
raise ValueError("graph_id must contain one graph ID per node")
if self.edge_index.numel() and (
self.edge_index.min() < 0 or self.edge_index.max() >= n
):
raise ValueError("edge_index contains a node index outside node_ids")
if (
self.node_ids.dtype not in (torch.int32, torch.int64)
or self.node_type_ids.dtype not in (torch.int32, torch.int64)
or self.edge_type_ids.dtype not in (torch.int32, torch.int64)
or self.graph_id.dtype not in (torch.int32, torch.int64)
):
raise TypeError(
"node, type, edge-type, and graph IDs must be integer tensors"
)
if self.edge_index.dtype not in (torch.int32, torch.int64):
raise TypeError("edge_index must be an integer tensor")
if self.node_type_hashes is not None:
_require_rank(self.node_type_hashes, 1, "node_type_hashes")
if self.node_type_hashes.shape[0] != n:
raise ValueError("node_type_hashes must have one entry per node")
if self.edge_type_hashes is not None:
_require_rank(self.edge_type_hashes, 1, "edge_type_hashes")
if self.edge_type_hashes.shape[0] != e:
raise ValueError("edge_type_hashes must have one entry per edge")
if (
not self.node_features.is_floating_point()
or not self.edge_features.is_floating_point()
):
raise TypeError(
"node_features and edge_features must be floating-point tensors"
)
@property
def num_nodes(self) -> int:
return int(self.node_ids.shape[0])
@property
def num_edges(self) -> int:
return int(self.edge_index.shape[1])
@property
def node_type_identity(self) -> Tensor:
"""Global node-type ids when present, else the local indices."""
return (
self.node_type_ids
if self.node_type_hashes is None
else self.node_type_hashes
)
@property
def edge_type_identity(self) -> Tensor:
"""Global relation ids when present, else the local indices."""
return (
self.edge_type_ids
if self.edge_type_hashes is None
else self.edge_type_hashes
)
def to(self, device: torch.device | str) -> GraphTensors:
return GraphTensors(
node_ids=self.node_ids.to(device),
node_type_ids=self.node_type_ids.to(device),
node_features=self.node_features.to(device),
edge_index=self.edge_index.to(device),
edge_type_ids=self.edge_type_ids.to(device),
edge_features=self.edge_features.to(device),
graph_id=self.graph_id.to(device),
node_type_hashes=None
if self.node_type_hashes is None
else self.node_type_hashes.to(device),
edge_type_hashes=None
if self.edge_type_hashes is None
else self.edge_type_hashes.to(device),
)
@dataclass(frozen=True)
class GraphQuery:
text: str | None = None
start_entities: tuple[str, ...] = ()
start_entity_ids: tuple[str, ...] = ()
allowed_relations: tuple[str, ...] = ()
required_relations: tuple[str, ...] = ()
required_relation_sequence: tuple[str, ...] = ()
forbidden_relations: tuple[str, ...] = ()
required_types: tuple[str, ...] = ()
forbidden_types: tuple[str, ...] = ()
required_intermediate_types: tuple[str, ...] = ()
max_hops: int = 3
min_hops: int = 1
exact_hops: int | None = None
return_paths: bool = True
verify: bool = True
operation: str = "find_entities"
answer_mode: str = "entity"
target_entity_ids: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.max_hops < 0 or self.min_hops < 0:
raise ValueError("hop bounds must be non-negative")
if self.min_hops > self.max_hops:
raise ValueError("min_hops cannot exceed max_hops")
if self.exact_hops is not None and not (
self.min_hops <= self.exact_hops <= self.max_hops
):
raise ValueError("exact_hops must be within min_hops and max_hops")
if self.answer_mode not in {
"entity",
"relation",
"boolean",
"count",
"aggregate",
}:
raise ValueError(f"unsupported answer_mode: {self.answer_mode!r}")
def canonical_text(self) -> str:
return "|".join(
(
self.operation,
self.text or "",
",".join(self.start_entity_ids or self.start_entities),
",".join(self.allowed_relations),
",".join(self.required_relations),
",".join(self.required_relation_sequence),
",".join(self.forbidden_relations),
",".join(self.required_types),
",".join(self.forbidden_types),
",".join(self.required_intermediate_types),
str(self.min_hops),
str(self.max_hops),
str(self.exact_hops),
self.answer_mode,
",".join(self.target_entity_ids),
)
)
@dataclass(frozen=True)
class PathStep:
source_id: str
relation_id: str
target_id: str
source_type: str | None = None
target_type: str | None = None
edge_attributes: Mapping[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class GraphPath:
steps: tuple[PathStep, ...]
score: float = 0.0
verified: bool = False
query_compatibility: float | None = None
@property
def hop_count(self) -> int:
return len(self.steps)
@property
def start_id(self) -> str | None:
return self.steps[0].source_id if self.steps else None
@property
def endpoint_id(self) -> str | None:
return self.steps[-1].target_id if self.steps else None
@property
def relation_sequence(self) -> tuple[str, ...]:
return tuple(step.relation_id for step in self.steps)
@dataclass(frozen=True)
class ConstraintState:
allowed_relations: frozenset[str] = frozenset()
required_relations: frozenset[str] = frozenset()
required_relation_sequence: tuple[str, ...] = ()
forbidden_relations: frozenset[str] = frozenset()
required_types: frozenset[str] = frozenset()
forbidden_types: frozenset[str] = frozenset()
required_intermediate_types: frozenset[str] = frozenset()
min_hops: int = 0
max_hops: int = 8
exact_hops: int | None = None
require_unique_nodes: bool = False
@classmethod
def from_query(cls, query: GraphQuery) -> ConstraintState:
return cls(
allowed_relations=frozenset(query.allowed_relations),
required_relations=frozenset(query.required_relations),
required_relation_sequence=query.required_relation_sequence,
forbidden_relations=frozenset(query.forbidden_relations),
required_types=frozenset(query.required_types),
forbidden_types=frozenset(query.forbidden_types),
required_intermediate_types=frozenset(query.required_intermediate_types),
min_hops=query.min_hops,
max_hops=query.max_hops,
exact_hops=query.exact_hops,
)
@dataclass(frozen=True)
class ReasoningState:
query_embedding: Tensor
entity_states: Tensor
relation_states: Tensor
candidate_nodes: Tensor
candidate_edges: Tensor
candidate_paths: tuple[GraphPath, ...]
path_representations: Tensor
hop: int
constraint_state: ConstraintState
evidence: tuple[GraphPath, ...] = ()
confidence: Tensor | None = None
@dataclass(frozen=True)
class EntityAnswer:
entity_id: str
score: float
@dataclass(frozen=True)
class ReasoningResult:
status: ReasoningStatus
answers: tuple[EntityAnswer, ...]
supporting_paths: tuple[GraphPath, ...]
conflicting_paths: tuple[GraphPath, ...]
confidence: float
calibrated: bool
model_version: str
graph_version: str
reasoning_steps: int
metadata: Mapping[str, Any] = field(default_factory=dict)
relation_answers: tuple[str, ...] = ()
boolean_answer: bool | None = None
aggregate_value: int | float | None = None
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status.value,
"answers": [
{"entity_id": a.entity_id, "score": a.score} for a in self.answers
],
"supporting_paths": [_path_to_dict(p) for p in self.supporting_paths],
"conflicting_paths": [_path_to_dict(p) for p in self.conflicting_paths],
"confidence": self.confidence,
"calibrated": self.calibrated,
"model_version": self.model_version,
"graph_version": self.graph_version,
"reasoning_steps": self.reasoning_steps,
"metadata": dict(self.metadata),
"relation_answers": list(self.relation_answers),
"boolean_answer": self.boolean_answer,
"aggregate_value": self.aggregate_value,
}
@dataclass(frozen=True)
class GraphExample:
graph: GraphTensors
node_external_ids: tuple[str, ...]
node_types: tuple[str, ...]
relation_external_ids: tuple[str, ...]
relation_names: tuple[str, ...]
edge_attributes: tuple[Mapping[str, str], ...] = ()
node_attributes: tuple[Mapping[str, str], ...] = ()
relation_attributes: tuple[Mapping[str, str], ...] = ()
graph_version: str = "synthetic@0"
def __post_init__(self) -> None:
if len(self.node_external_ids) != self.graph.num_nodes:
raise ValueError("node_external_ids must align with graph nodes")
if len(self.node_types) != self.graph.num_nodes:
raise ValueError("node_types must align with graph nodes")
if len(self.edge_attributes) not in (0, self.graph.num_edges):
raise ValueError("edge_attributes must be empty or align with graph edges")
if len(self.node_attributes) not in (0, self.graph.num_nodes):
raise ValueError("node_attributes must be empty or align with graph nodes")
if len(self.relation_external_ids) != len(self.relation_names):
raise ValueError("relation IDs and names must align")
if len(self.relation_attributes) not in (0, len(self.relation_external_ids)):
raise ValueError(
"relation_attributes must be empty or align with relations"
)
if len(set(self.node_external_ids)) != len(self.node_external_ids):
raise ValueError("node_external_ids must be unique")
@property
def node_index(self) -> Mapping[str, int]:
return {value: i for i, value in enumerate(self.node_external_ids)}
def _require_rank(value: Tensor, rank: int, name: str) -> None:
if value.ndim != rank:
raise ValueError(
f"{name} must have rank {rank}, got shape {tuple(value.shape)}"
)
def _path_to_dict(path: GraphPath) -> dict[str, Any]:
return {
"steps": [
{
"source_id": step.source_id,
"relation_id": step.relation_id,
"target_id": step.target_id,
"source_type": step.source_type,
"target_type": step.target_type,
"edge_attributes": dict(step.edge_attributes),
}
for step in path.steps
],
"score": path.score,
"verified": path.verified,
"query_compatibility": path.query_compatibility,
}
|