1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Bound indexed retrieval operations

This commit is contained in:
Andraxion 2026-07-29 04:09:28 -04:00
parent ad4f52b239
commit c69cd16515
6 changed files with 208 additions and 46 deletions

View file

@ -59,9 +59,11 @@ of project size.
The inherited dependency-cycle preparation scanned every edge once for every node. The graph The inherited dependency-cycle preparation scanned every edge once for every node. The graph
validator now constructs dependency adjacency in one edge pass and sorts each adjacency list before validator now constructs dependency adjacency in one edge pass and sorts each adjacency list before
the existing deterministic depth-first cycle check. an iterative deterministic depth-first cycle check. The iterative stack also removes recursion
depth as a failure mode on large valid graphs. The same edge pass now rejects missing sources as
well as missing targets.
A 2,000-node regression test counts complete edge-collection iteration passes and caps them at A 10,000-node regression test counts complete edge-collection iteration passes and caps them at
four. The focused correctness and bounded-pass tests pass, and the configured strict source type four. The focused correctness and bounded-pass tests pass, and the configured strict source type
gate is clean. gate is clean.
@ -137,6 +139,21 @@ One audit identified a correctness risk beyond latency: a large mutating MCP ope
successfully and then be replaced by `result_too_large`. This must be fixed in Milestone 1 so successfully and then be replaced by `result_too_large`. This must be fixed in Milestone 1 so
exactly-once operations never report a false failure after mutation. exactly-once operations never report a false failure after mutation.
#### Bounded indexed retrieval
Search, metadata filtering, backlinks, dependency traversal, and impact traversal now query one
extra row beyond the requested bound and report `limit` plus `truncated`. Backlinks, dependency,
and impact APIs accept the same additive `limit` option through Python, CLI, and MCP surfaces.
Omitted limits are capped by the project `max_results` policy.
Traversal no longer loads the complete edge table and repeatedly scans it. It performs
deterministically ordered frontier queries through the existing source primary key or target index.
Each request also has a deterministic edge-examination budget derived from its result limit. The
response includes `examined_edges` and `examined_edges_limit` counters so algorithmic work can be
asserted independently of machine timing. `truncated` is true when either another unique result
exists or the work budget prevents proving completeness. A focused core, CLI, MCP, Ruff, and
Pyright gate passes for this work-in-progress slice.
### Initial design constraints ### Initial design constraints
- Full rebuild remains the recovery and equivalence oracle. - Full rebuild remains the recovery and equivalence oracle.

View file

@ -53,6 +53,7 @@ def _parser() -> argparse.ArgumentParser:
command.add_argument("--relation") command.add_argument("--relation")
else: else:
command.add_argument("--depth", type=int, default=2) command.add_argument("--depth", type=int, default=2)
command.add_argument("--limit", type=int)
context = commands.add_parser("context") context = commands.add_parser("context")
context.add_argument("profile") context.add_argument("profile")
context.add_argument("--budget", type=int) context.add_argument("--budget", type=int)
@ -149,11 +150,23 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
limit=arguments.limit, limit=arguments.limit,
) )
if arguments.command == "backlinks": if arguments.command == "backlinks":
return index.backlinks(arguments.node_id, relation=arguments.relation) return index.backlinks(
arguments.node_id,
relation=arguments.relation,
limit=arguments.limit,
)
if arguments.command == "dependencies": if arguments.command == "dependencies":
return index.dependencies(arguments.node_id, depth=arguments.depth) return index.dependencies(
arguments.node_id,
depth=arguments.depth,
limit=arguments.limit,
)
if arguments.command == "impact": if arguments.command == "impact":
return index.impact(arguments.node_id, depth=arguments.depth) return index.impact(
arguments.node_id,
depth=arguments.depth,
limit=arguments.limit,
)
if arguments.command == "context": if arguments.command == "context":
return compile_context(index, arguments.profile, arguments.budget) return compile_context(index, arguments.profile, arguments.budget)
if arguments.command == "render": if arguments.command == "render":

View file

@ -826,14 +826,20 @@ class ProjectIndex:
ORDER BY rank, nodes.node_id ORDER BY rank, nodes.node_id
LIMIT ? LIMIT ?
""", """,
(expression, bounded), (expression, bounded + 1),
).fetchall() ).fetchall()
results: list[dict[str, object]] = [] results: list[dict[str, object]] = []
for row in rows: for row in rows[:bounded]:
payload = _row_to_node(row).as_dict(include_content=False) payload = _row_to_node(row).as_dict(include_content=False)
payload.update({"rank": row["rank"], "snippet": row["snippet"]}) payload.update({"rank": row["rank"], "snippet": row["snippet"]})
results.append(payload) results.append(payload)
return snapshot.result(query=query, count=len(results), results=results) return snapshot.result(
query=query,
count=len(results),
limit=bounded,
truncated=len(rows) > bounded,
results=results,
)
def filter_nodes( def filter_nodes(
self, self,
@ -857,66 +863,146 @@ class ProjectIndex:
where = f"WHERE {' AND '.join(clauses)}" if clauses else "" where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self._read_snapshot() as snapshot: with self._read_snapshot() as snapshot:
rows = snapshot.connection.execute( rows = snapshot.connection.execute(
f"SELECT * FROM nodes {where} ORDER BY node_id LIMIT ?", (*values, bounded) f"SELECT * FROM nodes {where} ORDER BY node_id LIMIT ?",
(*values, bounded + 1),
).fetchall() ).fetchall()
results = [_row_to_node(row).as_dict(include_content=False) for row in rows] results = [_row_to_node(row).as_dict(include_content=False) for row in rows[:bounded]]
return snapshot.result(count=len(results), results=results) return snapshot.result(
count=len(results),
limit=bounded,
truncated=len(rows) > bounded,
results=results,
)
def backlinks(self, node_id: str, *, relation: str | None = None) -> dict[str, object]: def backlinks(
return self._edges(node_id, incoming=True, relation=relation) self,
node_id: str,
*,
relation: str | None = None,
limit: int | None = None,
) -> dict[str, object]:
return self._edges(node_id, incoming=True, relation=relation, limit=limit)
def dependencies(self, node_id: str, *, depth: int = 2) -> dict[str, object]: def dependencies(
return self._traverse(node_id, incoming=False, depth=depth, relation="depends_on") self,
node_id: str,
*,
depth: int = 2,
limit: int | None = None,
) -> dict[str, object]:
return self._traverse(
node_id,
incoming=False,
depth=depth,
relation="depends_on",
limit=limit,
)
def impact(self, node_id: str, *, depth: int = 2) -> dict[str, object]: def impact(
return self._traverse(node_id, incoming=True, depth=depth, relation=None) self,
node_id: str,
*,
depth: int = 2,
limit: int | None = None,
) -> dict[str, object]:
return self._traverse(
node_id,
incoming=True,
depth=depth,
relation=None,
limit=limit,
)
def _edges(self, node_id: str, *, incoming: bool, relation: str | None) -> dict[str, object]: def _edges(
self,
node_id: str,
*,
incoming: bool,
relation: str | None,
limit: int | None,
) -> dict[str, object]:
bounded = _bounded_limit(
limit,
self.project.descriptor.limits.max_results,
default=self.project.descriptor.limits.max_results,
)
source_column = "target_id" if incoming else "source_id" source_column = "target_id" if incoming else "source_id"
relation_clause = " AND relation = ?" if relation is not None else "" relation_clause = " AND relation = ?" if relation is not None else ""
values: tuple[object, ...] = (node_id, relation) if relation is not None else (node_id,) values: tuple[object, ...] = (
(node_id, relation, bounded + 1) if relation is not None else (node_id, bounded + 1)
)
with self._read_snapshot() as snapshot: with self._read_snapshot() as snapshot:
self._require_node(snapshot.connection, node_id) self._require_node(snapshot.connection, node_id)
rows = snapshot.connection.execute( rows = snapshot.connection.execute(
f"SELECT source_id, relation, target_id FROM edges " f"SELECT source_id, relation, target_id FROM edges "
f"WHERE {source_column} = ?{relation_clause} " f"WHERE {source_column} = ?{relation_clause} "
"ORDER BY source_id, relation, target_id", "ORDER BY source_id, relation, target_id LIMIT ?",
values, values,
).fetchall() ).fetchall()
return snapshot.result(edges=[Edge(*row).as_dict() for row in rows]) truncated = len(rows) > bounded
edges = [Edge(*row).as_dict() for row in rows[:bounded]]
return snapshot.result(
count=len(edges),
limit=bounded,
truncated=truncated,
edges=edges,
)
def _traverse( def _traverse(
self, node_id: str, *, incoming: bool, depth: int, relation: str | None self,
node_id: str,
*,
incoming: bool,
depth: int,
relation: str | None,
limit: int | None,
) -> dict[str, object]: ) -> dict[str, object]:
maximum = self.project.descriptor.limits.max_traversal_depth maximum = self.project.descriptor.limits.max_traversal_depth
if type(depth) is not int or depth < 0 or depth > maximum: if type(depth) is not int or depth < 0 or depth > maximum:
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit") raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
bounded = _bounded_limit(
limit,
self.project.descriptor.limits.max_results,
default=self.project.descriptor.limits.max_results,
)
examined_limit = (bounded + 1) ** 2
source_column = "target_id" if incoming else "source_id"
relation_clause = " AND relation = ?" if relation is not None else ""
with self._read_snapshot() as snapshot: with self._read_snapshot() as snapshot:
self._require_node(snapshot.connection, node_id) self._require_node(snapshot.connection, node_id)
edges = tuple(
Edge(*row)
for row in snapshot.connection.execute(
"SELECT source_id, relation, target_id FROM edges "
"ORDER BY source_id, relation, target_id"
)
)
queue: deque[tuple[str, int, tuple[str, ...]]] = deque([(node_id, 0, (node_id,))]) queue: deque[tuple[str, int, tuple[str, ...]]] = deque([(node_id, 0, (node_id,))])
seen = {node_id} seen = {node_id}
results: list[dict[str, object]] = [] results: list[dict[str, object]] = []
while queue: truncated = False
examined_edges = 0
while queue and len(results) <= bounded and examined_edges < examined_limit:
current, current_depth, path = queue.popleft() current, current_depth, path = queue.popleft()
if current_depth >= depth: if current_depth >= depth:
continue continue
candidates = [ remaining = examined_limit - examined_edges
edge values: tuple[object, ...] = (
for edge in edges (current, relation, remaining + 1)
if (relation is None or edge.relation == relation) if relation is not None
and ((edge.target_id if incoming else edge.source_id) == current) else (current, remaining + 1)
] )
for edge in candidates: candidates = snapshot.connection.execute(
"SELECT source_id, relation, target_id FROM edges "
f"WHERE {source_column} = ?{relation_clause} "
"ORDER BY source_id, relation, target_id LIMIT ?",
values,
).fetchall()
if len(candidates) > remaining:
truncated = True
candidates = candidates[:remaining]
examined_edges += len(candidates)
for row in candidates:
edge = Edge(*row)
target = edge.source_id if incoming else edge.target_id target = edge.source_id if incoming else edge.target_id
if target in seen: if target in seen:
continue continue
if len(results) >= bounded:
truncated = True
break
seen.add(target) seen.add(target)
target_path = (*path, target) target_path = (*path, target)
results.append( results.append(
@ -928,10 +1014,16 @@ class ProjectIndex:
} }
) )
queue.append((target, current_depth + 1, target_path)) queue.append((target, current_depth + 1, target_path))
if queue and examined_edges >= examined_limit:
truncated = True
return snapshot.result( return snapshot.result(
root=node_id, root=node_id,
depth=depth, depth=depth,
count=len(results), count=len(results),
limit=bounded,
truncated=truncated,
examined_edges=examined_edges,
examined_edges_limit=examined_limit,
results=results, results=results,
) )

View file

@ -577,22 +577,36 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
) )
@server.tool(name="docforge_backlinks") @server.tool(name="docforge_backlinks")
def backlinks(node_id: str, relation: str | None = None) -> dict[str, Any]: def backlinks(
node_id: str,
relation: str | None = None,
limit: int | None = None,
) -> dict[str, Any]:
"""Return bounded incoming relationships for one exact stable node.""" """Return bounded incoming relationships for one exact stable node."""
return service.invoke(lambda: service.index.backlinks(node_id, relation=relation)) return service.invoke(
lambda: service.index.backlinks(node_id, relation=relation, limit=limit)
)
@server.tool(name="docforge_dependencies") @server.tool(name="docforge_dependencies")
def dependencies(node_id: str, depth: int = 2) -> dict[str, Any]: def dependencies(
node_id: str,
depth: int = 2,
limit: int | None = None,
) -> dict[str, Any]:
"""Traverse declared depends_on relationships within the configured depth limit.""" """Traverse declared depends_on relationships within the configured depth limit."""
return service.invoke(lambda: service.index.dependencies(node_id, depth=depth)) return service.invoke(lambda: service.index.dependencies(node_id, depth=depth, limit=limit))
@server.tool(name="docforge_impact") @server.tool(name="docforge_impact")
def impact(node_id: str, depth: int = 2) -> dict[str, Any]: def impact(
node_id: str,
depth: int = 2,
limit: int | None = None,
) -> dict[str, Any]:
"""Traverse bounded incoming relationships and report exact paths.""" """Traverse bounded incoming relationships and report exact paths."""
return service.invoke(lambda: service.index.impact(node_id, depth=depth)) return service.invoke(lambda: service.index.impact(node_id, depth=depth, limit=limit))
@server.tool(name="docforge_get_context") @server.tool(name="docforge_get_context")
def get_context(profile: str, budget: int | None = None) -> dict[str, Any]: def get_context(profile: str, budget: int | None = None) -> dict[str, Any]:

View file

@ -333,6 +333,9 @@ class DocForgeCoreTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
["proof.validation"], [item["node_id"] for item in filtered["results"]] ["proof.validation"], [item["node_id"] for item in filtered["results"]]
) )
limited_filter = index.filter_nodes(limit=1)
self.assertEqual(1, limited_filter["count"])
self.assertTrue(limited_filter["truncated"])
dependencies = index.dependencies("guide.workflow", depth=2) dependencies = index.dependencies("guide.workflow", depth=2)
self.assertEqual( self.assertEqual(
["guide.foundation"], [item["node_id"] for item in dependencies["results"]] ["guide.foundation"], [item["node_id"] for item in dependencies["results"]]
@ -346,6 +349,23 @@ class DocForgeCoreTests(unittest.TestCase):
["guide.workflow", "proof.validation"], ["guide.workflow", "proof.validation"],
[item["node_id"] for item in impact["results"]], [item["node_id"] for item in impact["results"]],
) )
limited = index.impact("guide.foundation", depth=2, limit=1)
self.assertEqual(["guide.workflow"], [item["node_id"] for item in limited["results"]])
self.assertEqual(1, limited["limit"])
self.assertTrue(limited["truncated"])
self.assertLessEqual(
limited["examined_edges"],
limited["examined_edges_limit"],
)
complete_limit = index.dependencies("guide.workflow", depth=2, limit=1)
self.assertEqual(1, complete_limit["count"])
self.assertFalse(complete_limit["truncated"])
limited_backlinks = index.backlinks("guide.workflow", limit=1)
self.assertEqual(1, limited_backlinks["count"])
self.assertEqual(1, limited_backlinks["limit"])
self.assertFalse(limited_backlinks["truncated"])
def test_query_rechecks_source_identity_before_returning(self) -> None: def test_query_rechecks_source_identity_before_returning(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:

View file

@ -131,9 +131,15 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
("docforge_get_logic", {"owner_node_id": "guide.workflow"}), ("docforge_get_logic", {"owner_node_id": "guide.workflow"}),
("docforge_search", {"query": "canonical nodes", "limit": 5}), ("docforge_search", {"query": "canonical nodes", "limit": 5}),
("docforge_filter_nodes", {"family": "proof", "tag": "validation"}), ("docforge_filter_nodes", {"family": "proof", "tag": "validation"}),
("docforge_backlinks", {"node_id": "guide.workflow"}), ("docforge_backlinks", {"node_id": "guide.workflow", "limit": 5}),
("docforge_dependencies", {"node_id": "guide.workflow", "depth": 2}), (
("docforge_impact", {"node_id": "guide.foundation", "depth": 2}), "docforge_dependencies",
{"node_id": "guide.workflow", "depth": 2, "limit": 5},
),
(
"docforge_impact",
{"node_id": "guide.foundation", "depth": 2, "limit": 5},
),
("docforge_get_context", {"profile": "active", "budget": 180}), ("docforge_get_context", {"profile": "active", "budget": 180}),
("docforge_validate_project", {}), ("docforge_validate_project", {}),
("docforge_render_status", {}), ("docforge_render_status", {}),