|
1 | 1 | """Bounded, presentation-neutral navigation over persisted graph authority.""" |
2 | 2 |
|
| 3 | +from collections import deque |
| 4 | +from collections.abc import Collection |
| 5 | +from dataclasses import dataclass |
| 6 | +import inspect |
3 | 7 | import typing |
4 | 8 |
|
| 9 | +import pydantic |
5 | 10 | import sqlmodel |
6 | 11 |
|
7 | 12 | from app.business.info_base.block import BlockManager |
8 | 13 | from app.business.info_base.relation import RelationManager |
9 | 14 | from app.engine import SessionLocal |
10 | 15 | from app.schemas.graph_navigation_retrieval import ( |
11 | 16 | BlockNeighborhood, |
| 17 | + ConnectedComponentsResult, |
| 18 | + ConnectedSeedComponent, |
12 | 19 | GraphDirection, |
13 | 20 | GraphModel, |
14 | 21 | PathFound, |
|
28 | 35 | DEFAULT_MAX_EXPLORED_BLOCKS = 1000 |
29 | 36 | MAX_MAX_EXPLORED_BLOCKS = 10000 |
30 | 37 | FRONTIER_QUERY_SIZE = 200 |
| 38 | +DEFAULT_MAX_EXPLORED_RELATIONS = 10000 |
| 39 | + |
| 40 | + |
| 41 | +@dataclass(frozen=True) |
| 42 | +class GraphNavigationQueryContract: |
| 43 | + name: str |
| 44 | + description: str |
| 45 | + input_model: type[pydantic.BaseModel] |
| 46 | + |
| 47 | + @property |
| 48 | + def input_schema(self) -> dict[str, typing.Any]: |
| 49 | + return self.input_model.model_json_schema() |
31 | 50 |
|
32 | 51 |
|
33 | 52 | class GraphNavigationRetrievalManager: |
34 | 53 | """Own graph-navigation semantics while hiding query and closure mechanics.""" |
35 | 54 |
|
| 55 | + @classmethod |
| 56 | + def get_query_contracts(cls) -> tuple[GraphNavigationQueryContract, ...]: |
| 57 | + """Describe public typed graph queries without exposing runtime sessions.""" |
| 58 | + contracts: list[GraphNavigationQueryContract] = [] |
| 59 | + names = ( |
| 60 | + name |
| 61 | + for name in dir(cls) |
| 62 | + if name.startswith(("get_", "find_")) |
| 63 | + and name not in {"get_query_contract", "get_query_contracts"} |
| 64 | + ) |
| 65 | + for name in names: |
| 66 | + function = getattr(cls, name) |
| 67 | + signature = inspect.signature(function, eval_str=True) |
| 68 | + fields: dict[str, tuple[typing.Any, typing.Any]] = {} |
| 69 | + for parameter in signature.parameters.values(): |
| 70 | + if parameter.name == "db_session": |
| 71 | + continue |
| 72 | + if parameter.annotation is inspect.Parameter.empty: |
| 73 | + raise TypeError(f"Graph query {name} parameter {parameter.name} must be typed") |
| 74 | + default = ... if parameter.default is inspect.Parameter.empty else parameter.default |
| 75 | + annotation = parameter.annotation |
| 76 | + if typing.get_origin(annotation) is Collection: |
| 77 | + item_type = typing.get_args(annotation)[0] |
| 78 | + annotation = tuple[item_type, ...] |
| 79 | + fields[parameter.name] = (annotation, default) |
| 80 | + input_model = typing.cast(typing.Any, pydantic.create_model)( |
| 81 | + f"GraphNavigation_{name}_Arguments", |
| 82 | + __config__=pydantic.ConfigDict(extra="forbid"), |
| 83 | + **fields, |
| 84 | + ) |
| 85 | + input_model.model_json_schema() |
| 86 | + contracts.append( |
| 87 | + GraphNavigationQueryContract( |
| 88 | + name=name, |
| 89 | + description=inspect.getdoc(function) or name.replace("_", " "), |
| 90 | + input_model=input_model, |
| 91 | + ) |
| 92 | + ) |
| 93 | + return tuple(contracts) |
| 94 | + |
| 95 | + @classmethod |
| 96 | + def get_query_contract(cls, name: str) -> GraphNavigationQueryContract | None: |
| 97 | + return next( |
| 98 | + (contract for contract in cls.get_query_contracts() if contract.name == name), |
| 99 | + None, |
| 100 | + ) |
| 101 | + |
| 102 | + @classmethod |
| 103 | + def invoke_query(cls, name: str, arguments: dict[str, typing.Any]) -> typing.Any: |
| 104 | + contract = cls.get_query_contract(name) |
| 105 | + if contract is None: |
| 106 | + raise ValueError("Graph navigation query is not available") |
| 107 | + validated = contract.input_model.model_validate(arguments) |
| 108 | + return getattr(cls, name)(**validated.model_dump()) |
| 109 | + |
36 | 110 | @classmethod |
37 | 111 | def get_random_block( |
38 | 112 | cls, |
@@ -143,6 +217,122 @@ def get_relation_neighborhood( |
143 | 217 | graph=GraphModel(blocks=blocks, relations=(relation,)), |
144 | 218 | ) |
145 | 219 |
|
| 220 | + @classmethod |
| 221 | + def get_connected_components( |
| 222 | + cls, |
| 223 | + seed_block_ids: typing.Collection[BlockID], |
| 224 | + *, |
| 225 | + contents: typing.Collection[str], |
| 226 | + max_explored_blocks: int = DEFAULT_MAX_EXPLORED_BLOCKS, |
| 227 | + max_explored_relations: int = DEFAULT_MAX_EXPLORED_RELATIONS, |
| 228 | + db_session: sqlmodel.Session | None = None, |
| 229 | + ) -> ConnectedComponentsResult: |
| 230 | + """Partition existing seeds by bounded undirected exact-content reachability.""" |
| 231 | + seeds = tuple(dict.fromkeys(seed_block_ids)) |
| 232 | + relation_contents = tuple(dict.fromkeys(contents)) |
| 233 | + if not relation_contents: |
| 234 | + raise ValueError("contents must not be empty") |
| 235 | + if max_explored_blocks < 1 or max_explored_relations < 1: |
| 236 | + raise ValueError("exploration bounds must be positive") |
| 237 | + if len(seeds) > max_explored_blocks: |
| 238 | + raise ValueError("seed blocks exceed max_explored_blocks") |
| 239 | + if db_session is None: |
| 240 | + with SessionLocal() as owned_session: |
| 241 | + return cls.get_connected_components( |
| 242 | + seeds, |
| 243 | + contents=relation_contents, |
| 244 | + max_explored_blocks=max_explored_blocks, |
| 245 | + max_explored_relations=max_explored_relations, |
| 246 | + db_session=owned_session, |
| 247 | + ) |
| 248 | + |
| 249 | + existing_blocks = BlockManager.get_many(seeds, db_session) |
| 250 | + existing_seed_ids = {block.id for block in existing_blocks if block.id is not None} |
| 251 | + missing = tuple(seed for seed in seeds if seed not in existing_seed_ids) |
| 252 | + assigned_seeds: set[BlockID] = set() |
| 253 | + explored_blocks = set(existing_seed_ids) |
| 254 | + seen_relations: set[RelationID] = set() |
| 255 | + explored_relation_count = 0 |
| 256 | + proof_relations: dict[RelationID, RelationModel] = {} |
| 257 | + components: list[ConnectedSeedComponent] = [] |
| 258 | + truncated = False |
| 259 | + |
| 260 | + for seed in seeds: |
| 261 | + if seed not in existing_seed_ids or seed in assigned_seeds: |
| 262 | + continue |
| 263 | + if truncated: |
| 264 | + components.append( |
| 265 | + ConnectedSeedComponent(seed_blocks=(seed,), member_blocks=(seed,)) |
| 266 | + ) |
| 267 | + assigned_seeds.add(seed) |
| 268 | + continue |
| 269 | + |
| 270 | + members = {seed} |
| 271 | + frontier = deque((seed,)) |
| 272 | + while frontier and not truncated: |
| 273 | + current = frontier.popleft() |
| 274 | + for endpoint in ("from", "to"): |
| 275 | + cursor: RelationID | None = None |
| 276 | + while not truncated: |
| 277 | + remaining = max_explored_relations - explored_relation_count |
| 278 | + if remaining == 0: |
| 279 | + truncated = True |
| 280 | + break |
| 281 | + requested = min(FRONTIER_QUERY_SIZE, remaining + 1) |
| 282 | + page = RelationManager.get_endpoint_page( |
| 283 | + (current,), |
| 284 | + endpoint=typing.cast(typing.Literal["from", "to"], endpoint), |
| 285 | + contents=relation_contents, |
| 286 | + cursor=cursor, |
| 287 | + limit=requested, |
| 288 | + db_session=db_session, |
| 289 | + ) |
| 290 | + if len(page) > remaining: |
| 291 | + page = page[:remaining] |
| 292 | + truncated = True |
| 293 | + explored_relation_count += len(page) |
| 294 | + for relation in page: |
| 295 | + if relation.id is None or relation.id in seen_relations: |
| 296 | + continue |
| 297 | + relation_id = relation.id |
| 298 | + seen_relations.add(relation_id) |
| 299 | + neighbor = relation.to_ if relation.from_ == current else relation.from_ |
| 300 | + if neighbor in members: |
| 301 | + continue |
| 302 | + if ( |
| 303 | + neighbor not in explored_blocks |
| 304 | + and len(explored_blocks) >= max_explored_blocks |
| 305 | + ): |
| 306 | + truncated = True |
| 307 | + break |
| 308 | + members.add(neighbor) |
| 309 | + explored_blocks.add(neighbor) |
| 310 | + frontier.append(neighbor) |
| 311 | + proof_relations[relation_id] = relation |
| 312 | + if truncated or len(page) < requested: |
| 313 | + break |
| 314 | + cursor = typing.cast(RelationID, page[-1].id) |
| 315 | + |
| 316 | + component_seeds = tuple(seed_id for seed_id in seeds if seed_id in members) |
| 317 | + assigned_seeds.update(component_seeds) |
| 318 | + components.append( |
| 319 | + ConnectedSeedComponent( |
| 320 | + seed_blocks=component_seeds, |
| 321 | + member_blocks=tuple(sorted(members)), |
| 322 | + ) |
| 323 | + ) |
| 324 | + |
| 325 | + proof_blocks = BlockManager.get_many(explored_blocks, db_session) |
| 326 | + return ConnectedComponentsResult( |
| 327 | + components=tuple(components), |
| 328 | + proof_graph=GraphModel( |
| 329 | + blocks=proof_blocks, |
| 330 | + relations=tuple(proof_relations.values()), |
| 331 | + ), |
| 332 | + missing_seed_blocks=missing, |
| 333 | + truncated=truncated, |
| 334 | + ) |
| 335 | + |
146 | 336 | @classmethod |
147 | 337 | def find_path( # noqa: PLR0913 |
148 | 338 | cls, |
|
0 commit comments