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

Refine bounded traversal contracts

This commit is contained in:
Andraxion 2026-07-29 04:15:13 -04:00
parent c69cd16515
commit 4ae9b31db5
8 changed files with 101 additions and 26 deletions

View file

@ -149,10 +149,17 @@ Omitted limits are capped by the project `max_results` policy.
Traversal no longer loads the complete edge table and repeatedly scans it. It performs 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. 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 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 response includes `candidate_edges_consumed`, `candidate_edges_limit`, and `truncation_reason`
asserted independently of machine timing. `truncated` is true when either another unique result counters so algorithmic work can be asserted independently of machine timing. `truncated` is true
exists or the work budget prevents proving completeness. A focused core, CLI, MCP, Ruff, and when either another unique result exists or the work budget prevents proving completeness.
Pyright gate passes for this work-in-progress slice.
The read-only query-plan audit found that source-ordered unfiltered incoming traversal required a
temporary SQLite sort with the version-2 `(target_id, relation, source_id)` index. Direct
`EXPLAIN QUERY PLAN` evidence showed `USE TEMP B-TREE FOR ORDER BY`. A measured additive
`(target_id, source_id, relation)` index removes that sort. The disposable index schema is now
version 3, so existing version-2 indexes rebuild without changing canonical source or proposals.
Frontier cursors are streamed and stop immediately on the first omitted unique result. A focused
core, CLI, MCP, Ruff, and Pyright gate passes for this work-in-progress slice.
### Initial design constraints ### Initial design constraints

View file

@ -68,7 +68,8 @@ Milestone 0 preserves:
- Edge schema version 1. - Edge schema version 1.
- Changeset schema version 1. - Changeset schema version 1.
- Result-envelope schema version 1. - Result-envelope schema version 1.
- SQLite index schema version 2. - SQLite index schema version 3. Version 2 indexes remain disposable and automatically rebuild;
version 3 adds a source-ordered incoming-edge index for bounded impact traversal.
- Index-attestation schema version 1. - Index-attestation schema version 1.
- Incremental extraction-cache schema version 1. - Incremental extraction-cache schema version 1.

View file

@ -38,6 +38,11 @@ returns the complete fixed binding, active index path, proposal and application
recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly. recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly.
Neither operation changes canonical sources. Neither operation changes canonical sources.
Search, filter, backlinks, dependencies, and impact accept explicit result limits bounded by the
project `max_results` policy. Omitted limits are still capped. Collection responses report whether
they were truncated. Traversal also reports whether truncation came from the result limit or its
deterministic candidate-edge work budget; it does not scan or materialize the complete edge table.
Adapter-backed servers also validate their process-start implementation fingerprint before every Adapter-backed servers also validate their process-start implementation fingerprint before every
tool. `adapter_restart_required` is stale but not synchronizable. Its remediation is tool. `adapter_restart_required` is stale but not synchronizable. Its remediation is
`restart_project_server`; the current process does not reload project code, update Git staging, or `restart_project_server`; the current process does not reload project code, update Git staging, or

View file

@ -463,9 +463,9 @@ validate-index
show NODE_ID show NODE_ID
search QUERY [--limit N] search QUERY [--limit N]
filter [--family X] [--authority X] [--status X] [--tag X] [--limit N] filter [--family X] [--authority X] [--status X] [--tag X] [--limit N]
backlinks NODE_ID [--relation RELATION] backlinks NODE_ID [--relation RELATION] [--limit N]
dependencies NODE_ID [--depth N] dependencies NODE_ID [--depth N] [--limit N]
impact NODE_ID [--depth N] impact NODE_ID [--depth N] [--limit N]
context PROFILE [--budget N] context PROFILE [--budget N]
``` ```

View file

@ -34,7 +34,7 @@ from .models import (
) )
from .project import project_root_fingerprint from .project import project_root_fingerprint
INDEX_SCHEMA_VERSION = 2 INDEX_SCHEMA_VERSION = 3
APPLICATION_ID = 1_146_683_778 APPLICATION_ID = 1_146_683_778
@ -265,6 +265,8 @@ class ProjectIndex:
PRIMARY KEY (source_id, relation, target_id) PRIMARY KEY (source_id, relation, target_id)
); );
CREATE INDEX edges_target ON edges(target_id, relation, source_id); CREATE INDEX edges_target ON edges(target_id, relation, source_id);
CREATE INDEX edges_target_source
ON edges(target_id, source_id, relation);
CREATE TABLE logic_owners ( CREATE TABLE logic_owners (
owner_node_id TEXT PRIMARY KEY, owner_node_id TEXT PRIMARY KEY,
source_id TEXT NOT NULL source_id TEXT NOT NULL
@ -838,6 +840,7 @@ class ProjectIndex:
count=len(results), count=len(results),
limit=bounded, limit=bounded,
truncated=len(rows) > bounded, truncated=len(rows) > bounded,
truncation_reason="result_limit" if len(rows) > bounded else None,
results=results, results=results,
) )
@ -871,6 +874,7 @@ class ProjectIndex:
count=len(results), count=len(results),
limit=bounded, limit=bounded,
truncated=len(rows) > bounded, truncated=len(rows) > bounded,
truncation_reason="result_limit" if len(rows) > bounded else None,
results=results, results=results,
) )
@ -942,9 +946,12 @@ class ProjectIndex:
truncated = len(rows) > bounded truncated = len(rows) > bounded
edges = [Edge(*row).as_dict() for row in rows[:bounded]] edges = [Edge(*row).as_dict() for row in rows[:bounded]]
return snapshot.result( return snapshot.result(
root=node_id,
relation=relation,
count=len(edges), count=len(edges),
limit=bounded, limit=bounded,
truncated=truncated, truncated=truncated,
truncation_reason="result_limit" if truncated else None,
edges=edges, edges=edges,
) )
@ -973,13 +980,16 @@ class ProjectIndex:
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]] = []
truncated = False truncation_reason: str | None = None
examined_edges = 0 candidate_edges_consumed = 0
while queue and len(results) <= bounded and examined_edges < examined_limit: while queue and truncation_reason is None:
current, current_depth, path = queue.popleft() current, current_depth, path = queue.popleft()
if current_depth >= depth: if current_depth >= depth:
continue continue
remaining = examined_limit - examined_edges remaining = examined_limit - candidate_edges_consumed
if remaining <= 0:
truncation_reason = "edge_examination_limit"
break
values: tuple[object, ...] = ( values: tuple[object, ...] = (
(current, relation, remaining + 1) (current, relation, remaining + 1)
if relation is not None if relation is not None
@ -990,18 +1000,18 @@ class ProjectIndex:
f"WHERE {source_column} = ?{relation_clause} " f"WHERE {source_column} = ?{relation_clause} "
"ORDER BY source_id, relation, target_id LIMIT ?", "ORDER BY source_id, relation, target_id LIMIT ?",
values, values,
).fetchall() )
if len(candidates) > remaining:
truncated = True
candidates = candidates[:remaining]
examined_edges += len(candidates)
for row in candidates: for row in candidates:
if candidate_edges_consumed >= examined_limit:
truncation_reason = "edge_examination_limit"
break
candidate_edges_consumed += 1
edge = Edge(*row) 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: if len(results) >= bounded:
truncated = True truncation_reason = "result_limit"
break break
seen.add(target) seen.add(target)
target_path = (*path, target) target_path = (*path, target)
@ -1014,16 +1024,15 @@ 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, limit=bounded,
truncated=truncated, truncated=truncation_reason is not None,
examined_edges=examined_edges, truncation_reason=truncation_reason,
examined_edges_limit=examined_limit, candidate_edges_consumed=candidate_edges_consumed,
candidate_edges_limit=examined_limit,
results=results, results=results,
) )

View file

@ -24,6 +24,22 @@ class DocForgeCliTests(unittest.TestCase):
shutil.copytree(FIXTURES / "alpha", root) shutil.copytree(FIXTURES / "alpha", root)
return root return root
def test_traversal_commands_accept_explicit_result_limits(self) -> None:
parser = _parser()
for command in ("backlinks", "dependencies", "impact"):
with self.subTest(command=command):
arguments = parser.parse_args(
[
"--project-root",
"/tmp/project",
command,
"guide.workflow",
"--limit",
"7",
]
)
self.assertEqual(7, arguments.limit)
def test_reindex_apply_and_visualization_commands_are_self_service(self) -> None: def test_reindex_apply_and_visualization_commands_are_self_service(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
parent = Path(directory) parent = Path(directory)

View file

@ -353,20 +353,31 @@ class DocForgeCoreTests(unittest.TestCase):
self.assertEqual(["guide.workflow"], [item["node_id"] for item in limited["results"]]) self.assertEqual(["guide.workflow"], [item["node_id"] for item in limited["results"]])
self.assertEqual(1, limited["limit"]) self.assertEqual(1, limited["limit"])
self.assertTrue(limited["truncated"]) self.assertTrue(limited["truncated"])
self.assertEqual("result_limit", limited["truncation_reason"])
self.assertLessEqual( self.assertLessEqual(
limited["examined_edges"], limited["candidate_edges_consumed"],
limited["examined_edges_limit"], limited["candidate_edges_limit"],
) )
complete_limit = index.dependencies("guide.workflow", depth=2, limit=1) complete_limit = index.dependencies("guide.workflow", depth=2, limit=1)
self.assertEqual(1, complete_limit["count"]) self.assertEqual(1, complete_limit["count"])
self.assertFalse(complete_limit["truncated"]) self.assertFalse(complete_limit["truncated"])
self.assertIsNone(complete_limit["truncation_reason"])
limited_backlinks = index.backlinks("guide.workflow", limit=1) limited_backlinks = index.backlinks("guide.workflow", limit=1)
self.assertEqual(1, limited_backlinks["count"]) self.assertEqual(1, limited_backlinks["count"])
self.assertEqual(1, limited_backlinks["limit"]) self.assertEqual(1, limited_backlinks["limit"])
self.assertFalse(limited_backlinks["truncated"]) self.assertFalse(limited_backlinks["truncated"])
for operation in (
lambda: index.backlinks("guide.workflow", limit=0),
lambda: index.dependencies("guide.workflow", limit=True),
lambda: index.impact("guide.workflow", limit=101),
):
with self.subTest(operation=operation), self.assertRaises(DocForgeError) as invalid:
operation()
self.assertEqual("invalid_limit", invalid.exception.code)
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:
root = self.copy_fixture("alpha", Path(directory)) root = self.copy_fixture("alpha", Path(directory))

View file

@ -70,6 +70,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
names = tuple(tool.name for tool in response.tools) names = tuple(tool.name for tool in response.tools)
self.assertEqual(ALL_TOOLS, names) self.assertEqual(ALL_TOOLS, names)
self.assertEqual(14, len(PROPOSAL_TOOLS)) self.assertEqual(14, len(PROPOSAL_TOOLS))
tools = {tool.name: tool for tool in response.tools}
for name in (
"docforge_backlinks",
"docforge_dependencies",
"docforge_impact",
):
self.assertIn("limit", tools[name].inputSchema["properties"])
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
self.assertFalse( self.assertFalse(
any( any(
token in name token in name
@ -193,6 +201,24 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertLessEqual(context["estimated_tokens"], 180) self.assertLessEqual(context["estimated_tokens"], 180)
self.assertTrue(context["omissions"]) self.assertTrue(context["omissions"])
async def test_invalid_traversal_limit_is_a_structured_domain_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
async with create_connected_server_and_client_session(
create_server(root), raise_exceptions=True
) as session:
result = await session.call_tool(
"docforge_impact",
{"node_id": "guide.foundation", "limit": 0},
)
self.assertEqual("error", result.structuredContent["status"])
self.assertEqual(
"invalid_limit",
result.structuredContent["error"]["code"],
)
async def test_sync_register_rebase_apply_and_lifecycle_are_one_bound_workflow( async def test_sync_register_rebase_apply_and_lifecycle_are_one_bound_workflow(
self, self,
) -> None: ) -> None: