diff --git a/README.md b/README.md index acce88e..2ef1d32 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,10 @@ pip install -e '.[mcp]' neuron-graph-rag-mcp --database /absolute/path/to/knowledge.db ``` +同じ adapter は SQLite 正本の判断を読む専用 tool `search_judgments`、`get_judgment`、`traverse_judgments` も公開します。これらは通常 `search` と異なり retrieval trace、activation、feedback を保存せず、read-only です。例えば `search_judgments` の `repository` には `liplus-language` や `neuron-graph-rag` の namespace を指定でき、archived judgment は `include_archived=true` を明示した時だけ検索・探索対象に入ります。接続先は workspace 固有 path を commit せず、上記の `/absolute/path/to/knowledge.db` を各 client の絶対 path へ置き換えます。 + +この接続例は local stdio の実装済み範囲だけを示します。Wiki 自動生成、共有フォルダ同期、remote / on-prem 配置は含みません。 + To opt one local stdio server into the evaluated stabilization settings, pass both feedback options explicitly: ```bash diff --git a/docs/canonical-sqlite-judgment-graph.md b/docs/canonical-sqlite-judgment-graph.md index d542009..748efde 100644 --- a/docs/canonical-sqlite-judgment-graph.md +++ b/docs/canonical-sqlite-judgment-graph.md @@ -16,6 +16,18 @@ archive は通常 retrieval から外す論理的忘却であり、revision、pr MCP の `write_judgment` は同じ domain API へ写像し、model に raw SQL を公開しない。既存 `search`、`record_source_use`、`record_outcome` の contract と既定値は変更しない。 +## Read-only API + +`NeuronGraphRAG.judgments` は `search_judgments`、`get_judgment`、`traverse_judgments` も提供する。三操作は current judgment state を SQLite 正本から読み、judgment、revision、relation、retrieval trace、feedback、node、edge、activation を成功時・失敗時とも変更しない。 + +`search_judgments(query, limit=5, include_archived=False, repository=None)` は judgment の `nodes` projection に既存 NGR と同じ lexical / dense scorer と process の有効 weight を適用する。結果は stable identity、current revision、lifecycle、statement、rationale、provenance、outgoing typed relation、score と lexical / dense の説明内訳を返す。既定候補は active のみで、archived は `include_archived=True` の時だけ候補に入る。`repository` は provenance の full repository または stable identity に使う basename namespace の完全一致で絞り込む。この検索は通常 `search` と異なり trace と動的 activation を保存せず、feedback の入力にもならない。 + +`get_judgment(judgment_id)` は stable identity の完全一致で lifecycle を問わず current state を返す。`traverse_judgments(judgment_id, direction="outgoing", relation_type=None, max_hops=1, include_archived=False)` は relation type と `incoming` / `outgoing` / `both` を指定できる。探索は root を再訪しない cycle-safe BFS とし、hop、到達した judgment の stable identity、source identity、target identity、relation type、direction の順で決定的に返す。archived node は明示指定時だけ traversal result に含める。 + +optional MCP adapter は同名の三 tool をこの domain API へ写像し、`readOnlyHint=true`、`destructiveHint=false`、`idempotentHint=true`、`openWorldHint=false` を宣言する。input / output schema は未知 field を拒否し、失敗は既存 `{code, message, retryable}` envelope を使う。 + +この実装範囲は local SQLite core と optional local stdio MCP adapter の読み取り surface までである。SQLite からの Wiki 自動生成、共有フォルダ同期、remote / on-prem deployment は実装しておらず、将来の配置判断とする。 + ## Portability and recovery `tools/judgment_graph.py` は次を提供する。 diff --git a/docs/optional-mcp-interface.md b/docs/optional-mcp-interface.md index f3897b0..1a44cb0 100644 --- a/docs/optional-mcp-interface.md +++ b/docs/optional-mcp-interface.md @@ -67,7 +67,7 @@ neuron-graph-rag-mcp \ ## 2. Protocol envelope -tool 名は `search`、`record_source_use`、`record_outcome` とする。すべての input と成功 output は JSON Schema で宣言し、未知 field を受け付けない。 +tool 名は `search`、`record_source_use`、`record_outcome`、`write_judgment`、`search_judgments`、`get_judgment`、`traverse_judgments` とする。すべての input と成功 output は JSON Schema で宣言し、未知 field を受け付けない。 成功時は MCP envelope の `resultType` を `complete` とし、機械処理用の `structuredContent` と、その同じ JSON を直列化した `TextContent` を返す。これは [MCP 2026-07-28 tools specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) の tool result、structured content、後方互換性の指針に合わせる。 @@ -94,9 +94,15 @@ tool の意味をこの文書だけに閉じ込めない。MCP client が `tools | `search` | `false` | `false` | `false` | `false` | | `record_source_use` | `false` | `false` | `true` | `false` | | `record_outcome` | `false` | `false` | `true` | `false` | +| `write_judgment` | `false` | `true` | `false` | `false` | +| `search_judgments` | `true` | `false` | `true` | `false` | +| `get_judgment` | `true` | `false` | `true` | `false` | +| `traverse_judgments` | `true` | `false` | `true` | `false` | `search` は edge を強化しないが、retrieval trace と動的 activation を保存するため read-only ではない。annotation は表示上の hint であり、認証・認可の代替ではない。 +judgment 専用三 tool は通常 `search` の feedback loop から分離されている。`search_judgments` は trace ID を発行せず、`get_judgment` は stable identity を完全一致で取得し、`traverse_judgments` は有限 hop の typed relation を決定的に辿る。三 tool は成功時・失敗時とも persistent table を変更しない。`search_judgments` と `traverse_judgments` は既定で active judgment だけを返し、archived は `include_archived=true` の時だけ含める。 + ## 3. Shared identifiers and validation ### 3.1 `trace_id` diff --git a/docs/requirements.md b/docs/requirements.md index 20940af..2f41d50 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -28,7 +28,7 @@ 10. 活性値は半減期に従って時間減衰する。 11. 活性減衰はノード確信度とエッジ事実性を変更しない。 12. 同一コーパスで通常のハイブリッド検索とグラフ統合検索を比較できる。 -13. 任意 MCP adapter の `search`、`record_source_use`、`record_outcome` 契約を、実装と transport から独立して定義する。 +13. 任意 MCP adapter の `search`、`record_source_use`、`record_outcome`、`write_judgment` と judgment 専用 read tool 契約を、実装と transport から独立して定義する。 14. source-use を `retrieved`、`selected`、`validated`、`used` に分け、新規 `used` への遷移だけを即時 reinforcement に接続する。 15. `corrected`、`rolled_back` などの delayed outcome を source-use と別に記録し、初期契約では edge weight を自動変更しない。 16. MCP adapter は trace、node、enum、stage 順序、idempotency を境界で検証する。 @@ -106,6 +106,7 @@ 87. outcome-driven deactivation evaluation はcontrol / candidate、`corrected` / `rolled_back` / `superseded`、exact credited / sibling inverse、baseline floor、dormancy / reactivation、rank / locality、source isolation、exclusive outputを結果観測前に固定する。protocolはregistered output不在のfreeze-only PRで固定し、そのsquash merge後のsuccessor Issueでdevelopmentを一度だけ実行する。全hard gate通過時だけholdoutを一度開き、観測前後にquery、case、schedule、metric、gate、default、live configを変更しない。 88. NGR 自身の新規 judgment graph は SQLite の stable identity、revision、lifecycle、provenance、typed relation を machine-readable 正本とする。add / update / supersede / archive / restore / hard-delete candidate は raw SQL でなく atomic domain API を通し、stale revision、dangling relation、部分更新、二重 successor を fail closed にする。archive は通常 retrieval から外す論理的忘却、hard delete は履歴参照のない archived candidate だけに許す物理削除として分離する。current graph の deterministic export / atomic import と SQLite backup / integrity-checked restore を維持し、既存 Wiki entry の本番移行は fixture 検証後に分離する。 89. Li+ / NGR Decision Structure Wiki pilot は各 repository の index が列挙する entry だけを専用の新規 SQLite へ取り込み、repository namespace 付き identity、page 本文、Wiki URL、repository、取得 commit、source state、typed relation を保持する。duplicate identity、unknown relation target、parser ambiguity、partial publication、既存出力の上書きを fail closed にし、SQLite / supersession integrity、deterministic export、backup を検証する。Wiki、既存検索 DB、凍結済み feedback 実験 DB は変更せず、本 pilot だけで正本を切り替えない。 +90. judgment 専用 read API は `search_judgments`、`get_judgment`、`traverse_judgments` を提供し、current revision、lifecycle、statement、rationale、provenance、typed relation を返す。search は judgment の既存 node projection に同じ lexical / dense scorer と有効 weight を適用し、既定で active のみ、明示指定時だけ archived を含め、repository namespace で絞り込める。traversal は relation type、incoming / outgoing / both、1 以上の有限 hop を受け、cycle-safe な hop 優先・stable identity 順を維持する。三操作と対応 MCP tool は成功時・失敗時とも judgment、revision、relation、retrieval trace、feedback、node、edge、activation を永続変更せず、MCP は read-only annotation、未知 field を拒否する schema、既存 error envelope を持つ。既存 `search`、feedback、`write_judgment`、library default は変更しない。 75. v3 implementation、prompt、manifest、query override、schema、集約、path audit、hash規則、gate、stop rule、testsをresult-free commitでpushした後、development stage / 4 case packet / 12 responses / resultを各一度だけ生成する。 76. development全12 gate通過時だけholdout stageを一度生成し、異なるfresh 12 judgesで同じgateを評価する。packet、response、resultの上書き、観測後の規則変更、実LLM品質値のCI再生成を拒否する。 diff --git a/src/neuron_graph_rag/engine.py b/src/neuron_graph_rag/engine.py index ac8dc3f..8321482 100644 --- a/src/neuron_graph_rag/engine.py +++ b/src/neuron_graph_rag/engine.py @@ -135,9 +135,16 @@ def __init__( ) -> None: self.config = config or EngineConfig() self.store = SQLiteStore(database) - self.judgments = JudgmentGraph(self.store) self.sparse_retriever = BM25Retriever() self.dense_retriever = DenseRetriever(dense_encoder) + self.judgments = JudgmentGraph( + self.store, + sparse_retriever=self.sparse_retriever, + dense_retriever=self.dense_retriever, + sparse_weight=self.config.sparse_weight, + dense_weight=self.config.dense_weight, + use_dense_retrieval=self.config.use_dense_retrieval, + ) def close(self) -> None: self.store.close() diff --git a/src/neuron_graph_rag/judgments.py b/src/neuron_graph_rag/judgments.py index 10f4015..a9a933d 100644 --- a/src/neuron_graph_rag/judgments.py +++ b/src/neuron_graph_rag/judgments.py @@ -6,6 +6,7 @@ from typing import Any, Iterable from .models import DocumentNode, TypedEdge +from .retrieval import BM25Retriever, DenseRetriever, normalize_scores from .storage import SQLiteStore @@ -20,8 +21,22 @@ class JudgmentContractError(ValueError): class JudgmentGraph: """Atomic domain API for the canonical SQLite judgment graph.""" - def __init__(self, store: SQLiteStore) -> None: + def __init__( + self, + store: SQLiteStore, + *, + sparse_retriever: BM25Retriever | None = None, + dense_retriever: DenseRetriever | None = None, + sparse_weight: float = 0.55, + dense_weight: float = 0.45, + use_dense_retrieval: bool = True, + ) -> None: self.store = store + self.sparse_retriever = sparse_retriever or BM25Retriever() + self.dense_retriever = dense_retriever or DenseRetriever() + self.sparse_weight = sparse_weight + self.dense_weight = dense_weight + self.use_dense_retrieval = use_dense_retrieval @staticmethod def _now() -> str: @@ -211,6 +226,9 @@ def hard_delete(self, judgment_id: str, *, expected_revision: int) -> None: connection.execute("DELETE FROM judgments WHERE judgment_id = ?", (judgment_id,)) def get(self, judgment_id: str) -> dict[str, Any]: + return self.get_judgment(judgment_id) + + def get_judgment(self, judgment_id: str) -> dict[str, Any]: judgment_id = self._identity(judgment_id) row = self.store.connection.execute( """ @@ -234,6 +252,194 @@ def get(self, judgment_id: str) -> dict[str, Any]: "relations": [dict(item) for item in relations], } + def search_judgments( + self, + query: str, + *, + limit: int = 5, + include_archived: bool = False, + repository: str | None = None, + ) -> list[dict[str, Any]]: + query = self._text(query, "query") + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100: + raise JudgmentContractError("limit must be an integer from 1 through 100") + if not isinstance(include_archived, bool): + raise JudgmentContractError("include_archived must be a boolean") + repository = self._repository(repository) + + rows = self.store.connection.execute( + "SELECT judgment_id FROM judgments ORDER BY judgment_id" + ).fetchall() + candidates: list[tuple[dict[str, Any], DocumentNode]] = [] + for row in rows: + judgment = self.get_judgment(row["judgment_id"]) + if judgment["lifecycle"] != "active" and not include_archived: + continue + if repository is not None and not self._repository_matches( + judgment["provenance"], repository + ): + continue + candidates.append((judgment, self.store.get_node(judgment["judgment_id"]))) + if not candidates: + return [] + + nodes = [node for _, node in candidates] + sparse_raw = self.sparse_retriever.score(query, nodes) + dense_raw = ( + self.dense_retriever.score(query, nodes) + if self.use_dense_retrieval + else {node.node_id: 0.0 for node in nodes} + ) + sparse = normalize_scores(sparse_raw) + dense = normalize_scores(dense_raw) + weight_total = self.sparse_weight + self.dense_weight + results: list[dict[str, Any]] = [] + for judgment, node in candidates: + score = ( + sparse[node.node_id] * self.sparse_weight + + dense[node.node_id] * self.dense_weight + ) / weight_total + results.append( + { + **judgment, + "score": score, + "explanation": { + "sparse_score": sparse[node.node_id], + "dense_score": dense[node.node_id], + "sparse_weight": self.sparse_weight, + "dense_weight": self.dense_weight, + }, + } + ) + results.sort(key=lambda item: (-item["score"], item["judgment_id"])) + return results[:limit] + + def traverse_judgments( + self, + judgment_id: str, + *, + direction: str = "outgoing", + relation_type: str | None = None, + max_hops: int = 1, + include_archived: bool = False, + ) -> list[dict[str, Any]]: + root = self.get_judgment(judgment_id) + if direction not in {"incoming", "outgoing", "both"}: + raise JudgmentContractError("direction must be incoming, outgoing, or both") + if relation_type is not None: + if not isinstance(relation_type, str) or _RELATION.fullmatch(relation_type) is None: + raise JudgmentContractError("invalid relation type") + if ( + isinstance(max_hops, bool) + or not isinstance(max_hops, int) + or not 1 <= max_hops <= 32 + ): + raise JudgmentContractError("max_hops must be an integer from 1 through 32") + if not isinstance(include_archived, bool): + raise JudgmentContractError("include_archived must be a boolean") + if root["lifecycle"] != "active" and not include_archived: + raise JudgmentContractError("archived root requires include_archived") + + visited = {root["judgment_id"]} + frontier = [root["judgment_id"]] + traversed: list[dict[str, Any]] = [] + for hop in range(1, max_hops + 1): + next_frontier: list[str] = [] + for current_id in frontier: + edges = self._traversal_edges( + current_id, direction=direction, relation_type=relation_type + ) + for source_id, target_id, kind, edge_direction, neighbor_id in edges: + if neighbor_id in visited: + continue + judgment = self.get_judgment(neighbor_id) + if judgment["lifecycle"] != "active" and not include_archived: + continue + visited.add(neighbor_id) + next_frontier.append(neighbor_id) + traversed.append( + { + "hop": hop, + "direction": edge_direction, + "relation": { + "source_id": source_id, + "target_id": target_id, + "relation_type": kind, + }, + "judgment": judgment, + } + ) + frontier = sorted(next_frontier) + if not frontier: + break + traversed.sort( + key=lambda item: ( + item["hop"], + item["judgment"]["judgment_id"], + item["relation"]["source_id"], + item["relation"]["target_id"], + item["relation"]["relation_type"], + item["direction"], + ) + ) + return traversed + + def _traversal_edges( + self, + judgment_id: str, + *, + direction: str, + relation_type: str | None, + ) -> list[tuple[str, str, str, str, str]]: + edges: list[tuple[str, str, str, str, str]] = [] + if direction in {"outgoing", "both"}: + rows = self.store.connection.execute( + """ + SELECT source_id, target_id, relation_type + FROM judgment_relations WHERE source_id = ? + ORDER BY target_id, relation_type + """, + (judgment_id,), + ).fetchall() + edges.extend( + (row["source_id"], row["target_id"], row["relation_type"], "outgoing", row["target_id"]) + for row in rows + if relation_type is None or row["relation_type"] == relation_type + ) + if direction in {"incoming", "both"}: + rows = self.store.connection.execute( + """ + SELECT source_id, target_id, relation_type + FROM judgment_relations WHERE target_id = ? + ORDER BY source_id, relation_type + """, + (judgment_id,), + ).fetchall() + edges.extend( + (row["source_id"], row["target_id"], row["relation_type"], "incoming", row["source_id"]) + for row in rows + if relation_type is None or row["relation_type"] == relation_type + ) + return sorted(edges, key=lambda item: (item[4], item[2], item[3], item[0], item[1])) + + @staticmethod + def _repository(value: str | None) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise JudgmentContractError("repository must be a string") + normalized = value.strip() + if not normalized or len(normalized) > 256 or any(ord(character) < 32 for character in normalized): + raise JudgmentContractError("repository is invalid") + return normalized + + @staticmethod + def _repository_matches(provenance: dict[str, Any], repository: str) -> bool: + stored = provenance.get("repository") + if not isinstance(stored, str): + return False + return stored == repository or stored.rsplit("/", 1)[-1] == repository + def export(self) -> dict[str, Any]: ids = [row[0] for row in self.store.connection.execute("SELECT judgment_id FROM judgments ORDER BY judgment_id")] return {"format": "ngr-judgment-graph/v1", "judgments": [self.get(item) for item in ids]} diff --git a/src/neuron_graph_rag_mcp/server.py b/src/neuron_graph_rag_mcp/server.py index ed9cd70..f788bf5 100644 --- a/src/neuron_graph_rag_mcp/server.py +++ b/src/neuron_graph_rag_mcp/server.py @@ -97,6 +97,22 @@ "later confirmed outcome on that saved path reactivates it. Unattributed, duplicate, " "lexical, and zero-hop outcomes remain non-mutating." ) +JUDGMENT_SEARCH_DESCRIPTION = ( + "Search only canonical SQLite judgments without creating a retrieval trace or changing " + "activation, feedback, nodes, edges, revisions, lifecycle, or relations. Results include " + "the current revision, lifecycle, statement, rationale, provenance, typed relations, " + "and score explanation. Active judgments are the default; include archived judgments " + "only when explicitly requested." +) +JUDGMENT_GET_DESCRIPTION = ( + "Get the current state of one canonical SQLite judgment by exact stable identity. This " + "operation is read-only and returns archived judgments as well as active judgments." +) +JUDGMENT_TRAVERSE_DESCRIPTION = ( + "Traverse canonical SQLite judgment relations by type, direction, and finite hop count. " + "Traversal is cycle-safe, deterministic, and read-only. Active judgments are the default; " + "include archived judgments only when explicitly requested." +) _IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]+$") _TRACE = re.compile(r"^[0-9a-f]{32}$") @@ -174,6 +190,37 @@ def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]: "required": ["contract_version", "action", "judgment_id"], "additionalProperties": False, } +JUDGMENT_SEARCH_INPUT = _object( + { + "contract_version": {"type": "string", "const": CONTRACT_VERSION}, + "query": {"type": "string", "minLength": 1, "maxLength": 8192}, + "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 5}, + "include_archived": {"type": "boolean", "default": False}, + "repository": {"type": "string", "minLength": 1, "maxLength": 256}, + }, + ["contract_version", "query"], +) +JUDGMENT_GET_INPUT = _object( + { + "contract_version": {"type": "string", "const": CONTRACT_VERSION}, + "judgment_id": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + ["contract_version", "judgment_id"], +) +JUDGMENT_TRAVERSE_INPUT = _object( + { + "contract_version": {"type": "string", "const": CONTRACT_VERSION}, + "judgment_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "direction": { + "type": "string", "enum": ["incoming", "outgoing", "both"], + "default": "outgoing", + }, + "relation_type": {"type": "string", "minLength": 1, "maxLength": 64}, + "max_hops": {"type": "integer", "minimum": 1, "maximum": 32, "default": 1}, + "include_archived": {"type": "boolean", "default": False}, + }, + ["contract_version", "judgment_id"], +) _STEP_OUTPUT = _object( { @@ -402,11 +449,89 @@ def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]: ["contract_version", "outcome_id", "trace_id", "node_ids", "outcome", "recorded_at", "reinforcement_applied"], ) +_JUDGMENT_RELATION_OUTPUT = _object( + { + "target_id": {"type": "string"}, + "relation_type": {"type": "string"}, + }, + ["target_id", "relation_type"], +) +_JUDGMENT_PROPERTIES = { + "judgment_id": {"type": "string"}, + "revision": {"type": "integer", "minimum": 1}, + "statement": {"type": "string"}, + "rationale": {"type": "string"}, + "provenance": {"type": "object"}, + "lifecycle": {"type": "string", "enum": ["active", "archived"]}, + "superseded_by": {"type": ["string", "null"]}, + "relations": {"type": "array", "items": _JUDGMENT_RELATION_OUTPUT}, +} +_JUDGMENT_REQUIRED = list(_JUDGMENT_PROPERTIES) +JUDGMENT_OUTPUT = _object(dict(_JUDGMENT_PROPERTIES), _JUDGMENT_REQUIRED) +JUDGMENT_SEARCH_RESULT_OUTPUT = _object( + { + **_JUDGMENT_PROPERTIES, + "score": {"type": "number"}, + "explanation": _object( + { + "sparse_score": {"type": "number"}, + "dense_score": {"type": "number"}, + "sparse_weight": {"type": "number"}, + "dense_weight": {"type": "number"}, + }, + ["sparse_score", "dense_score", "sparse_weight", "dense_weight"], + ), + }, + [*_JUDGMENT_REQUIRED, "score", "explanation"], +) +JUDGMENT_SEARCH_OUTPUT = _object( + { + "contract_version": {"type": "string", "const": CONTRACT_VERSION}, + "judgments": {"type": "array", "items": JUDGMENT_SEARCH_RESULT_OUTPUT}, + }, + ["contract_version", "judgments"], +) +JUDGMENT_GET_OUTPUT = _object( + { + "contract_version": {"type": "string", "const": CONTRACT_VERSION}, + "judgment": JUDGMENT_OUTPUT, + }, + ["contract_version", "judgment"], +) +_TRAVERSED_RELATION_OUTPUT = _object( + { + "source_id": {"type": "string"}, + "target_id": {"type": "string"}, + "relation_type": {"type": "string"}, + }, + ["source_id", "target_id", "relation_type"], +) +JUDGMENT_TRAVERSE_OUTPUT = _object( + { + "contract_version": {"type": "string", "const": CONTRACT_VERSION}, + "results": { + "type": "array", + "items": _object( + { + "hop": {"type": "integer", "minimum": 1}, + "direction": {"type": "string", "enum": ["incoming", "outgoing"]}, + "relation": _TRAVERSED_RELATION_OUTPUT, + "judgment": JUDGMENT_OUTPUT, + }, + ["hop", "direction", "relation", "judgment"], + ), + }, + }, + ["contract_version", "results"], +) -def _annotations(*, idempotent: bool) -> types.ToolAnnotations: + +def _annotations( + *, idempotent: bool, read_only: bool = False, destructive: bool = False +) -> types.ToolAnnotations: return types.ToolAnnotations( - read_only_hint=False, - destructive_hint=False, + read_only_hint=read_only, + destructive_hint=destructive, idempotent_hint=idempotent, open_world_hint=False, ) @@ -450,10 +575,28 @@ def _annotations(*, idempotent: bool) -> types.ToolAnnotations: }, ["contract_version", "action", "judgment"], ), - annotations=types.ToolAnnotations( - read_only_hint=False, destructive_hint=True, idempotent_hint=False, - open_world_hint=False, - ), + annotations=_annotations(idempotent=False, destructive=True), + ), + types.Tool( + name="search_judgments", + description=JUDGMENT_SEARCH_DESCRIPTION, + input_schema=JUDGMENT_SEARCH_INPUT, + output_schema=JUDGMENT_SEARCH_OUTPUT, + annotations=_annotations(idempotent=True, read_only=True), + ), + types.Tool( + name="get_judgment", + description=JUDGMENT_GET_DESCRIPTION, + input_schema=JUDGMENT_GET_INPUT, + output_schema=JUDGMENT_GET_OUTPUT, + annotations=_annotations(idempotent=True, read_only=True), + ), + types.Tool( + name="traverse_judgments", + description=JUDGMENT_TRAVERSE_DESCRIPTION, + input_schema=JUDGMENT_TRAVERSE_INPUT, + output_schema=JUDGMENT_TRAVERSE_OUTPUT, + annotations=_annotations(idempotent=True, read_only=True), ), ) @@ -496,7 +639,7 @@ def _tools( output_schema=OUTCOME_OUTPUT, annotations=_annotations(idempotent=True), ), - TOOLS[3], + *TOOLS[3:], ) @@ -536,8 +679,14 @@ async def call_tool(self, _context: object, params: types.CallToolRequestParams) output = self._record_source_use(arguments) elif params.name == "record_outcome": output = self._record_outcome(arguments) - else: + elif params.name == "write_judgment": output = self._write_judgment(arguments) + elif params.name == "search_judgments": + output = self._search_judgments(arguments) + elif params.name == "get_judgment": + output = self._get_judgment(arguments) + else: + output = self._traverse_judgments(arguments) return self._success(output) except FeedbackContractError as error: return self._error(error.code, str(error), error.retryable) @@ -844,6 +993,56 @@ def _write_judgment(self, data: dict[str, Any]) -> dict[str, Any]: raise ValueError("unsupported judgment action") return {"contract_version": CONTRACT_VERSION, "action": action, "judgment": judgment} + def _search_judgments(self, data: dict[str, Any]) -> dict[str, Any]: + self._keys( + data, + {"contract_version", "query", "limit", "include_archived", "repository"}, + {"contract_version", "query"}, + ) + self._version(data) + query = self._trimmed_string(data["query"], "query", 8192) + limit = data.get("limit", 5) + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100: + raise ValueError("limit must be an integer from 1 through 100") + results = self.engine.judgments.search_judgments( + query, + limit=limit, + include_archived=data.get("include_archived", False), + repository=data.get("repository"), + ) + return {"contract_version": CONTRACT_VERSION, "judgments": results} + + def _get_judgment(self, data: dict[str, Any]) -> dict[str, Any]: + self._keys( + data, + {"contract_version", "judgment_id"}, + {"contract_version", "judgment_id"}, + ) + self._version(data) + return { + "contract_version": CONTRACT_VERSION, + "judgment": self.engine.judgments.get_judgment(data["judgment_id"]), + } + + def _traverse_judgments(self, data: dict[str, Any]) -> dict[str, Any]: + self._keys( + data, + { + "contract_version", "judgment_id", "direction", "relation_type", + "max_hops", "include_archived", + }, + {"contract_version", "judgment_id"}, + ) + self._version(data) + results = self.engine.judgments.traverse_judgments( + data["judgment_id"], + direction=data.get("direction", "outgoing"), + relation_type=data.get("relation_type"), + max_hops=data.get("max_hops", 1), + include_archived=data.get("include_archived", False), + ) + return {"contract_version": CONTRACT_VERSION, "results": results} + @staticmethod def _success(output: dict[str, Any]) -> types.CallToolResult: text = json.dumps(output, ensure_ascii=False, sort_keys=True, separators=(",", ":")) diff --git a/tests/test_judgments.py b/tests/test_judgments.py index 3fbf930..f5f48e9 100644 --- a/tests/test_judgments.py +++ b/tests/test_judgments.py @@ -13,6 +13,22 @@ class JudgmentGraphTests(unittest.TestCase): + @staticmethod + def _persistent_state(engine: NeuronGraphRAG) -> dict[str, list[tuple[object, ...]]]: + tables = [ + row[0] + for row in engine.store.connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + ] + return { + table: sorted( + (tuple(row) for row in engine.store.connection.execute(f'SELECT * FROM "{table}"')), + key=repr, + ) + for table in tables + } + def test_lifecycle_supersede_and_audit_history(self) -> None: with NeuronGraphRAG() as engine: engine.judgments.add("base", "Use the old path", "Initial decision", PROVENANCE) @@ -180,6 +196,94 @@ def test_deterministic_round_trip_and_backup_restore(self) -> None: with NeuronGraphRAG(restored) as engine: self.assertEqual(engine.judgments.export(), expected) + def test_read_api_search_get_filters_and_never_mutates(self) -> None: + with NeuronGraphRAG() as engine: + engine.judgments.add( + "one:cache-policy", + "Prefer bounded cache invalidation", + "The fallback must remain deterministic", + {"repository": "Liplus-Project/one", "source": "wiki"}, + ) + engine.judgments.add( + "two:cache-policy", + "Archive the legacy cache policy", + "The replacement is active", + {"repository": "Liplus-Project/two", "source": "wiki"}, + ) + engine.judgments.archive("two:cache-policy", expected_revision=1) + before = self._persistent_state(engine) + + default_results = engine.judgments.search_judgments("cache policy") + self.assertEqual( + [item["judgment_id"] for item in default_results], + ["one:cache-policy"], + ) + self.assertEqual(default_results[0]["revision"], 1) + self.assertIn("score", default_results[0]) + self.assertEqual( + set(default_results[0]["explanation"]), + {"sparse_score", "dense_score", "sparse_weight", "dense_weight"}, + ) + archived = engine.judgments.search_judgments( + "cache policy", + include_archived=True, + repository="two", + ) + self.assertEqual([item["judgment_id"] for item in archived], ["two:cache-policy"]) + exact = engine.judgments.get_judgment("two:cache-policy") + self.assertEqual(exact["lifecycle"], "archived") + with self.assertRaises(JudgmentContractError): + engine.judgments.search_judgments(" ") + + self.assertEqual(self._persistent_state(engine), before) + + def test_read_api_traversal_is_filtered_cycle_safe_and_deterministic(self) -> None: + with NeuronGraphRAG() as engine: + engine.judgments.add("a", "Alpha", "Reason", PROVENANCE) + engine.judgments.add( + "b", "Beta", "Reason", PROVENANCE, + relations=[{"target_id": "a", "relation_type": "supports"}], + ) + engine.judgments.add( + "c", "Gamma", "Reason", PROVENANCE, + relations=[{"target_id": "b", "relation_type": "depends_on"}], + ) + engine.judgments.update( + "a", "Alpha", "Reason", PROVENANCE, expected_revision=1, + relations=[{"target_id": "c", "relation_type": "informs"}], + ) + before = self._persistent_state(engine) + + outgoing = engine.judgments.traverse_judgments("a", max_hops=3) + self.assertEqual( + [(item["hop"], item["judgment"]["judgment_id"]) for item in outgoing], + [(1, "c"), (2, "b")], + ) + incoming = engine.judgments.traverse_judgments( + "a", direction="incoming", relation_type="supports", max_hops=3 + ) + self.assertEqual( + [(item["hop"], item["judgment"]["judgment_id"]) for item in incoming], + [(1, "b")], + ) + both = engine.judgments.traverse_judgments("a", direction="both", max_hops=3) + self.assertEqual( + [(item["hop"], item["judgment"]["judgment_id"]) for item in both], + [(1, "b"), (1, "c")], + ) + engine.judgments.archive("b", expected_revision=1) + archived_state = self._persistent_state(engine) + self.assertEqual(engine.judgments.traverse_judgments("a", direction="incoming"), []) + included = engine.judgments.traverse_judgments( + "a", direction="incoming", max_hops=2, include_archived=True + ) + self.assertEqual( + [(item["hop"], item["judgment"]["judgment_id"]) for item in included], + [(1, "b"), (2, "c")], + ) + self.assertEqual(self._persistent_state(engine), archived_state) + self.assertNotEqual(before, archived_state) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index bca568f..80241c2 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -48,16 +48,38 @@ def tearDown(self) -> None: self.adapter.close() self.temporary.cleanup() + def _persistent_state(self) -> dict[str, list[tuple[object, ...]]]: + connection = self.adapter.engine.store.connection + tables = [ + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + ] + return { + table: sorted( + (tuple(row) for row in connection.execute(f'SELECT * FROM "{table}"')), + key=repr, + ) + for table in tables + } + async def test_tools_list_contract_and_structured_search_result(self) -> None: listed = await self.adapter.list_tools() - self.assertEqual([tool.name for tool in listed.tools], ["search", "record_source_use", "record_outcome", "write_judgment"]) + self.assertEqual( + [tool.name for tool in listed.tools], + [ + "search", "record_source_use", "record_outcome", "write_judgment", + "search_judgments", "get_judgment", "traverse_judgments", + ], + ) self.assertEqual( [tool.description for tool in listed.tools[:3]], [SEARCH_DESCRIPTION, SOURCE_USE_DESCRIPTION, OUTCOME_DESCRIPTION], ) self.assertEqual( [tool.annotations.idempotent_hint for tool in listed.tools], - [False, True, True, False], + [False, True, True, False, True, True, True], ) for tool in listed.tools[:3]: self.assertFalse(tool.annotations.read_only_hint) @@ -68,6 +90,13 @@ async def test_tools_list_contract_and_structured_search_result(self) -> None: self.assertTrue(listed.tools[3].annotations.destructive_hint) self.assertFalse(listed.tools[3].input_schema["additionalProperties"]) self.assertFalse(listed.tools[3].output_schema["additionalProperties"]) + for tool in listed.tools[4:]: + self.assertTrue(tool.annotations.read_only_hint) + self.assertFalse(tool.annotations.destructive_hint) + self.assertTrue(tool.annotations.idempotent_hint) + self.assertFalse(tool.annotations.open_world_hint) + self.assertFalse(tool.input_schema["additionalProperties"]) + self.assertFalse(tool.output_schema["additionalProperties"]) result = await self.adapter.call_tool( None, @@ -188,6 +217,89 @@ async def test_judgment_write_uses_atomic_domain_surface(self) -> None: "Use the domain API", ) + async def test_judgment_read_tools_are_filtered_exact_and_persist_nothing(self) -> None: + graph = self.adapter.engine.judgments + graph.add( + "one:root", "Root judgment", "Canonical root", + {"repository": "Liplus-Project/one", "source": "wiki"}, + ) + graph.add( + "one:child", "Child judgment", "Supports root", + {"repository": "Liplus-Project/one", "source": "wiki"}, + relations=[{"target_id": "one:root", "relation_type": "supports"}], + ) + graph.add( + "two:legacy", "Legacy judgment", "Archived state", + {"repository": "Liplus-Project/two", "source": "wiki"}, + ) + graph.archive("two:legacy", expected_revision=1) + before = self._persistent_state() + + searched = await self.adapter.call_tool( + None, + types.CallToolRequestParams( + name="search_judgments", + arguments={ + "contract_version": CONTRACT_VERSION, + "query": "judgment", + "repository": "one", + }, + ), + ) + self.assertFalse(searched.is_error) + self.assertEqual( + {item["judgment_id"] for item in searched.structured_content["judgments"]}, + {"one:root", "one:child"}, + ) + self.assertTrue(all("explanation" in item for item in searched.structured_content["judgments"])) + + exact = await self.adapter.call_tool( + None, + types.CallToolRequestParams( + name="get_judgment", + arguments={ + "contract_version": CONTRACT_VERSION, + "judgment_id": "two:legacy", + }, + ), + ) + self.assertFalse(exact.is_error) + self.assertEqual(exact.structured_content["judgment"]["lifecycle"], "archived") + + traversed = await self.adapter.call_tool( + None, + types.CallToolRequestParams( + name="traverse_judgments", + arguments={ + "contract_version": CONTRACT_VERSION, + "judgment_id": "one:root", + "direction": "incoming", + "relation_type": "supports", + "max_hops": 2, + }, + ), + ) + self.assertFalse(traversed.is_error) + self.assertEqual( + [item["judgment"]["judgment_id"] for item in traversed.structured_content["results"]], + ["one:child"], + ) + + invalid = await self.adapter.call_tool( + None, + types.CallToolRequestParams( + name="search_judgments", + arguments={ + "contract_version": CONTRACT_VERSION, + "query": "judgment", + "unexpected": True, + }, + ), + ) + self.assertTrue(invalid.is_error) + self.assertEqual(json.loads(invalid.content[0].text)["code"], "invalid_argument") + self.assertEqual(self._persistent_state(), before) + async def test_feedback_receipt_exposes_quorum_evidence_and_replays(self) -> None: self.adapter.close() self.adapter = FeedbackMCPAdapter( @@ -512,6 +624,12 @@ async def test_deactivation_candidate_receipt_description_and_provenance(self) - ) async def test_stdio_protocol_smoke(self) -> None: + self.adapter.engine.judgments.add( + "stdio:judgment", + "Read judgments through stdio", + "The optional adapter exposes the core read contract", + {"repository": "Liplus-Project/stdio"}, + ) parameters = StdioServerParameters( command=sys.executable, args=["-m", "neuron_graph_rag_mcp", "--database", str(self.database)], @@ -522,13 +640,31 @@ async def test_stdio_protocol_smoke(self) -> None: ): await session.initialize() listed = await session.list_tools() - self.assertEqual([tool.name for tool in listed.tools], ["search", "record_source_use", "record_outcome", "write_judgment"]) + self.assertEqual( + [tool.name for tool in listed.tools], + [ + "search", "record_source_use", "record_outcome", "write_judgment", + "search_judgments", "get_judgment", "traverse_judgments", + ], + ) result = await session.call_tool( "search", {"contract_version": CONTRACT_VERSION, "query": "cache"}, ) self.assertFalse(result.is_error) self.assertEqual(json.loads(result.content[0].text), result.structured_content) + judgment = await session.call_tool( + "get_judgment", + { + "contract_version": CONTRACT_VERSION, + "judgment_id": "stdio:judgment", + }, + ) + self.assertFalse(judgment.is_error) + self.assertEqual( + judgment.structured_content["judgment"]["statement"], + "Read judgments through stdio", + ) source_use = await session.call_tool( "record_source_use", {