Add versioned independent projection contracts
This commit is contained in:
parent
4c5773c865
commit
96e3965855
22 changed files with 3561 additions and 133 deletions
497
src/docforge/graph_projection.py
Normal file
497
src/docforge/graph_projection.py
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
"""Pure, bounded portable-graph planning over one immutable graph generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, cast
|
||||
|
||||
from .errors import DocForgeError
|
||||
from .models import Edge, Node, ProjectSnapshot
|
||||
from .project import project_root_fingerprint
|
||||
from .projection_contract import GraphViewPlanV1
|
||||
|
||||
GraphViewMode = Literal["nodes", "flow", "web", "logic"]
|
||||
|
||||
MAX_GRAPH_VIEW_DEPTH = 32
|
||||
MAX_GRAPH_VIEW_NODES = 1_000
|
||||
MAX_GRAPH_VIEW_EDGES = 4_000
|
||||
MAX_GRAPH_VIEW_WORK = 1_000_000
|
||||
MAX_GRAPH_VIEW_FILTERS = 64
|
||||
MAX_GRAPH_VIEW_STRING_CHARS = 1_024
|
||||
MAX_GRAPH_VIEW_QUERY_CHARS = 10_000
|
||||
|
||||
_QUERY_TOKEN = re.compile(r"\w+", re.UNICODE)
|
||||
_DETAIL_FIELDS = (
|
||||
"node_id",
|
||||
"title",
|
||||
"family",
|
||||
"authority",
|
||||
"status",
|
||||
"tags",
|
||||
"summary",
|
||||
"content_hash",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphViewRequestV1:
|
||||
"""One closed, inert graph selection request."""
|
||||
|
||||
view_id: str
|
||||
title: str
|
||||
root_node_id: str | None = None
|
||||
query: str | None = None
|
||||
initial_mode: GraphViewMode = "nodes"
|
||||
depth: int = 1
|
||||
max_nodes: int = 100
|
||||
max_edges: int = 400
|
||||
max_work: int = 100_000
|
||||
families: tuple[str, ...] = ()
|
||||
relations: tuple[str, ...] = ()
|
||||
authorities: tuple[str, ...] = ()
|
||||
statuses: tuple[str, ...] = ()
|
||||
tags: tuple[str, ...] = ()
|
||||
include_logic: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ValidatedRequest:
|
||||
view_id: str
|
||||
title: str
|
||||
root_node_id: str | None
|
||||
query: str | None
|
||||
initial_mode: GraphViewMode
|
||||
depth: int
|
||||
max_nodes: int
|
||||
max_edges: int
|
||||
max_work: int
|
||||
families: tuple[str, ...]
|
||||
relations: tuple[str, ...]
|
||||
authorities: tuple[str, ...]
|
||||
statuses: tuple[str, ...]
|
||||
tags: tuple[str, ...]
|
||||
include_logic: bool
|
||||
|
||||
|
||||
def _invalid(message: str, **details: object) -> DocForgeError:
|
||||
return DocForgeError("invalid_graph_view_request", message, **details)
|
||||
|
||||
|
||||
def _string(value: object, *, field: str, maximum: int = MAX_GRAPH_VIEW_STRING_CHARS) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > maximum or "\0" in value:
|
||||
raise _invalid("Graph view request string is invalid", field=field)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _filter_values(values: object, *, field: str) -> tuple[str, ...]:
|
||||
if not isinstance(values, tuple):
|
||||
raise _invalid("Graph view filter is invalid", field=field)
|
||||
tuple_values = cast(tuple[object, ...], values)
|
||||
if not all(isinstance(value, str) for value in tuple_values):
|
||||
raise _invalid("Graph view filter is invalid", field=field)
|
||||
if len(tuple_values) > MAX_GRAPH_VIEW_FILTERS:
|
||||
raise _invalid("Graph view filter is invalid", field=field)
|
||||
normalized = tuple(_string(value, field=field) for value in cast(tuple[str, ...], tuple_values))
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise _invalid("Graph view filter contains duplicates", field=field)
|
||||
return tuple(sorted(normalized))
|
||||
|
||||
|
||||
def _validated_request(request: GraphViewRequestV1) -> _ValidatedRequest:
|
||||
view_id = _string(request.view_id, field="view_id")
|
||||
title = _string(request.title, field="title")
|
||||
if (request.root_node_id is None) == (request.query is None):
|
||||
raise _invalid("Choose exactly one exact root or lexical query")
|
||||
root_node_id = (
|
||||
None
|
||||
if request.root_node_id is None
|
||||
else _string(request.root_node_id, field="root_node_id")
|
||||
)
|
||||
query = (
|
||||
None
|
||||
if request.query is None
|
||||
else _string(
|
||||
request.query,
|
||||
field="query",
|
||||
maximum=MAX_GRAPH_VIEW_QUERY_CHARS,
|
||||
)
|
||||
)
|
||||
if request.initial_mode not in {"nodes", "flow", "web", "logic"}:
|
||||
raise _invalid("Graph view initial mode is unsupported")
|
||||
if type(request.depth) is not int or not 1 <= request.depth <= MAX_GRAPH_VIEW_DEPTH:
|
||||
raise _invalid(
|
||||
"Graph view depth is outside the fixed boundary",
|
||||
maximum=MAX_GRAPH_VIEW_DEPTH,
|
||||
)
|
||||
for field, value, minimum, maximum in (
|
||||
("max_nodes", request.max_nodes, 1, MAX_GRAPH_VIEW_NODES),
|
||||
("max_edges", request.max_edges, 0, MAX_GRAPH_VIEW_EDGES),
|
||||
("max_work", request.max_work, 1, MAX_GRAPH_VIEW_WORK),
|
||||
):
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise _invalid(
|
||||
"Graph view bound is outside the fixed boundary",
|
||||
field=field,
|
||||
minimum=minimum,
|
||||
maximum=maximum,
|
||||
)
|
||||
if type(request.include_logic) is not bool:
|
||||
raise _invalid("Graph view Logic selection must be Boolean")
|
||||
return _ValidatedRequest(
|
||||
view_id=view_id,
|
||||
title=title,
|
||||
root_node_id=root_node_id,
|
||||
query=query,
|
||||
initial_mode=request.initial_mode,
|
||||
depth=request.depth,
|
||||
max_nodes=request.max_nodes,
|
||||
max_edges=request.max_edges,
|
||||
max_work=request.max_work,
|
||||
families=_filter_values(request.families, field="families"),
|
||||
relations=_filter_values(request.relations, field="relations"),
|
||||
authorities=_filter_values(request.authorities, field="authorities"),
|
||||
statuses=_filter_values(request.statuses, field="statuses"),
|
||||
tags=_filter_values(request.tags, field="tags"),
|
||||
include_logic=request.include_logic,
|
||||
)
|
||||
|
||||
|
||||
def _validated_graph(
|
||||
snapshot: ProjectSnapshot,
|
||||
) -> tuple[tuple[Node, ...], tuple[Edge, ...], dict[str, Node]]:
|
||||
nodes = tuple(sorted(snapshot.nodes, key=lambda node: node.node_id))
|
||||
node_by_id = {node.node_id: node for node in nodes}
|
||||
if len(node_by_id) != len(nodes):
|
||||
raise DocForgeError(
|
||||
"invalid_projection",
|
||||
"Graph view snapshot contains duplicate node identities",
|
||||
)
|
||||
edges = tuple(
|
||||
sorted(
|
||||
snapshot.edges,
|
||||
key=lambda edge: (edge.source_id, edge.relation, edge.target_id),
|
||||
)
|
||||
)
|
||||
edge_keys = {(edge.source_id, edge.relation, edge.target_id) for edge in edges}
|
||||
if len(edge_keys) != len(edges) or any(
|
||||
edge.source_id not in node_by_id or edge.target_id not in node_by_id for edge in edges
|
||||
):
|
||||
raise DocForgeError(
|
||||
"invalid_projection",
|
||||
"Graph view snapshot contains invalid relationships",
|
||||
)
|
||||
return nodes, edges, node_by_id
|
||||
|
||||
|
||||
def _eligible(node: Node, request: _ValidatedRequest) -> bool:
|
||||
return (
|
||||
(not request.families or node.family in request.families)
|
||||
and (not request.authorities or node.authority in request.authorities)
|
||||
and (not request.statuses or node.status in request.statuses)
|
||||
and (not request.tags or set(request.tags).issubset(node.tags))
|
||||
)
|
||||
|
||||
|
||||
def _relation_allowed(edge: Edge, request: _ValidatedRequest) -> bool:
|
||||
return not request.relations or edge.relation in request.relations
|
||||
|
||||
|
||||
def _lexical_text(node: Node) -> str:
|
||||
return " ".join(
|
||||
(
|
||||
node.node_id,
|
||||
node.title,
|
||||
node.summary,
|
||||
node.family,
|
||||
node.authority,
|
||||
node.status,
|
||||
*node.tags,
|
||||
)
|
||||
).casefold()
|
||||
|
||||
|
||||
def _lexical_nodes(
|
||||
nodes: tuple[Node, ...],
|
||||
request: _ValidatedRequest,
|
||||
omissions: list[dict[str, object]],
|
||||
) -> tuple[set[str], int, bool]:
|
||||
query = request.query
|
||||
maximum_nodes = request.max_nodes
|
||||
maximum_work = request.max_work
|
||||
assert query is not None
|
||||
terms = tuple(dict.fromkeys(_QUERY_TOKEN.findall(query.casefold())))
|
||||
if not terms:
|
||||
raise _invalid("Lexical graph scope contains no searchable text")
|
||||
selected: set[str] = set()
|
||||
work = 0
|
||||
work_limited = False
|
||||
for node in nodes:
|
||||
if work >= maximum_work:
|
||||
work_limited = True
|
||||
break
|
||||
work += 1
|
||||
if not _eligible(node, request) or not all(term in _lexical_text(node) for term in terms):
|
||||
continue
|
||||
if len(selected) >= maximum_nodes:
|
||||
omissions.append(
|
||||
{
|
||||
"code": "node_result_limit",
|
||||
"subject": "nodes",
|
||||
"limit": maximum_nodes,
|
||||
"minimum_omitted": 1,
|
||||
}
|
||||
)
|
||||
break
|
||||
selected.add(node.node_id)
|
||||
return selected, work, work_limited
|
||||
|
||||
|
||||
def _root_nodes(
|
||||
edges: tuple[Edge, ...],
|
||||
node_by_id: dict[str, Node],
|
||||
request: _ValidatedRequest,
|
||||
omissions: list[dict[str, object]],
|
||||
) -> tuple[set[str], int, bool]:
|
||||
root_node_id = request.root_node_id
|
||||
maximum_nodes = request.max_nodes
|
||||
maximum_work = request.max_work
|
||||
depth = request.depth
|
||||
assert root_node_id is not None
|
||||
root = node_by_id.get(root_node_id)
|
||||
if root is None:
|
||||
raise DocForgeError(
|
||||
"missing_node",
|
||||
"No node has the requested stable ID",
|
||||
node_id=root_node_id,
|
||||
)
|
||||
if not _eligible(root, request):
|
||||
raise _invalid("Exact graph root is excluded by the closed node filters")
|
||||
|
||||
selected = {root_node_id}
|
||||
frontier = {root_node_id}
|
||||
work = 0
|
||||
work_limited = False
|
||||
node_limited = False
|
||||
for _ in range(depth):
|
||||
if not frontier:
|
||||
break
|
||||
next_frontier: set[str] = set()
|
||||
for edge in edges:
|
||||
if work >= maximum_work:
|
||||
work_limited = True
|
||||
break
|
||||
work += 1
|
||||
if not _relation_allowed(edge, request):
|
||||
continue
|
||||
candidate: str | None = None
|
||||
if edge.source_id in frontier:
|
||||
candidate = edge.target_id
|
||||
elif edge.target_id in frontier:
|
||||
candidate = edge.source_id
|
||||
if candidate is None or candidate in selected:
|
||||
continue
|
||||
node = node_by_id[candidate]
|
||||
if not _eligible(node, request):
|
||||
continue
|
||||
if len(selected) >= maximum_nodes:
|
||||
node_limited = True
|
||||
continue
|
||||
selected.add(candidate)
|
||||
next_frontier.add(candidate)
|
||||
if work_limited:
|
||||
break
|
||||
frontier = next_frontier
|
||||
if node_limited:
|
||||
omissions.append(
|
||||
{
|
||||
"code": "node_result_limit",
|
||||
"subject": "nodes",
|
||||
"limit": maximum_nodes,
|
||||
"minimum_omitted": 1,
|
||||
}
|
||||
)
|
||||
return selected, work, work_limited
|
||||
|
||||
|
||||
def _selected_edges(
|
||||
edges: tuple[Edge, ...],
|
||||
selected_ids: set[str],
|
||||
request: _ValidatedRequest,
|
||||
*,
|
||||
initial_work: int,
|
||||
omissions: list[dict[str, object]],
|
||||
) -> tuple[list[Edge], int, bool]:
|
||||
maximum_edges = request.max_edges
|
||||
maximum_work = request.max_work
|
||||
selected: list[Edge] = []
|
||||
work = initial_work
|
||||
work_limited = False
|
||||
edge_limited = False
|
||||
for edge in edges:
|
||||
if work >= maximum_work:
|
||||
work_limited = True
|
||||
break
|
||||
work += 1
|
||||
if (
|
||||
edge.source_id not in selected_ids
|
||||
or edge.target_id not in selected_ids
|
||||
or not _relation_allowed(edge, request)
|
||||
):
|
||||
continue
|
||||
if len(selected) >= maximum_edges:
|
||||
edge_limited = True
|
||||
break
|
||||
selected.append(edge)
|
||||
if edge_limited:
|
||||
omissions.append(
|
||||
{
|
||||
"code": "edge_result_limit",
|
||||
"subject": "edges",
|
||||
"limit": maximum_edges,
|
||||
"minimum_omitted": 1,
|
||||
}
|
||||
)
|
||||
return selected, work, work_limited
|
||||
|
||||
|
||||
def _node_payload(node: Node) -> dict[str, object]:
|
||||
return {
|
||||
"node_id": node.node_id,
|
||||
"title": node.title,
|
||||
"family": node.family,
|
||||
"authority": node.authority,
|
||||
"status": node.status,
|
||||
"tags": sorted(node.tags),
|
||||
"summary": node.summary,
|
||||
"content_hash": node.content_hash,
|
||||
}
|
||||
|
||||
|
||||
def _edge_payload(edge: Edge) -> dict[str, str]:
|
||||
return {
|
||||
"source_id": edge.source_id,
|
||||
"relation": edge.relation,
|
||||
"target_id": edge.target_id,
|
||||
}
|
||||
|
||||
|
||||
def build_graph_view_plan(
|
||||
snapshot: ProjectSnapshot,
|
||||
request: GraphViewRequestV1,
|
||||
allow_logic: bool,
|
||||
) -> GraphViewPlanV1:
|
||||
"""Build one deterministic, path-free graph plan without rendering or storage access."""
|
||||
|
||||
if type(allow_logic) is not bool:
|
||||
raise _invalid("Graph view Logic policy must be Boolean")
|
||||
normalized = _validated_request(request)
|
||||
nodes, edges, node_by_id = _validated_graph(snapshot)
|
||||
omissions: list[dict[str, object]] = []
|
||||
if normalized.root_node_id is not None:
|
||||
selected_ids, work, work_limited = _root_nodes(
|
||||
edges,
|
||||
node_by_id,
|
||||
normalized,
|
||||
omissions,
|
||||
)
|
||||
scope: dict[str, object] = {
|
||||
"kind": "exact_root",
|
||||
"root_node_id": normalized.root_node_id,
|
||||
"depth": normalized.depth,
|
||||
}
|
||||
else:
|
||||
selected_ids, work, work_limited = _lexical_nodes(
|
||||
nodes,
|
||||
normalized,
|
||||
omissions,
|
||||
)
|
||||
scope = {
|
||||
"kind": "lexical",
|
||||
"query": normalized.query,
|
||||
}
|
||||
selected_edges, work, edge_work_limited = _selected_edges(
|
||||
edges,
|
||||
selected_ids,
|
||||
normalized,
|
||||
initial_work=work,
|
||||
omissions=omissions,
|
||||
)
|
||||
work_limited = work_limited or edge_work_limited
|
||||
if work_limited:
|
||||
omissions.append(
|
||||
{
|
||||
"code": "work_limit",
|
||||
"subject": "selection",
|
||||
"limit": normalized.max_work,
|
||||
"examined": work,
|
||||
"minimum_omitted": 1,
|
||||
}
|
||||
)
|
||||
logic_requested = normalized.include_logic
|
||||
if logic_requested and not allow_logic:
|
||||
omissions.append(
|
||||
{
|
||||
"code": "logic_forbidden",
|
||||
"subject": "logic",
|
||||
"minimum_omitted": 1,
|
||||
}
|
||||
)
|
||||
omissions.sort(key=lambda item: (str(item["code"]), str(item["subject"])))
|
||||
selected_nodes = [node_by_id[node_id] for node_id in sorted(selected_ids)]
|
||||
filters: dict[str, list[str]] = {
|
||||
"families": list(normalized.families),
|
||||
"relations": list(normalized.relations),
|
||||
"authorities": list(normalized.authorities),
|
||||
"statuses": list(normalized.statuses),
|
||||
"tags": list(normalized.tags),
|
||||
}
|
||||
return GraphViewPlanV1.create(
|
||||
{
|
||||
"project": {
|
||||
"project_id": snapshot.descriptor.project_id,
|
||||
"project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
|
||||
"adapter": snapshot.descriptor.adapter,
|
||||
"revision": snapshot.revision,
|
||||
"source_hash": snapshot.source_hash,
|
||||
},
|
||||
"view": {
|
||||
"view_id": normalized.view_id,
|
||||
"title": normalized.title,
|
||||
"initial_mode": normalized.initial_mode,
|
||||
"scope": scope,
|
||||
"filters": filters,
|
||||
"detail_fields": list(_DETAIL_FIELDS),
|
||||
},
|
||||
"bounds": {
|
||||
"depth": normalized.depth,
|
||||
"max_nodes": normalized.max_nodes,
|
||||
"max_edges": normalized.max_edges,
|
||||
"max_work": normalized.max_work,
|
||||
},
|
||||
"policy": {
|
||||
"visibility": "selected_graph_only",
|
||||
"source_paths": "excluded",
|
||||
"source_bodies": "excluded",
|
||||
"database_queries": "forbidden",
|
||||
"executable_content": "forbidden",
|
||||
"logic": "allowed" if allow_logic else "forbidden",
|
||||
"logic_requested": logic_requested,
|
||||
},
|
||||
"graph": {
|
||||
"root_node_id": normalized.root_node_id,
|
||||
"nodes": [_node_payload(node) for node in selected_nodes],
|
||||
"edges": [_edge_payload(edge) for edge in selected_edges],
|
||||
"logic_projections": [],
|
||||
},
|
||||
"omissions": omissions,
|
||||
"diagnostics": {
|
||||
"selection": scope["kind"],
|
||||
"returned_nodes": len(selected_nodes),
|
||||
"returned_edges": len(selected_edges),
|
||||
"examined_work_units": work,
|
||||
"truncated": bool(omissions),
|
||||
"ordering": "node_id;source_id,relation,target_id",
|
||||
},
|
||||
}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue