katalyst-plugin-context 0.2.1a11__tar.gz → 0.2.1a12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/PKG-INFO +1 -1
  2. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/pyproject.toml +1 -1
  3. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/adapters/runtime_graph.py +106 -2
  4. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/composition.py +6 -2
  5. katalyst_plugin_context-0.2.1a12/tests/test_cross_backend_edges.py +287 -0
  6. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/.gitignore +0 -0
  7. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/Justfile +0 -0
  8. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/__init__.py +0 -0
  9. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
  10. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/cli.py +0 -0
  11. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/domain/__init__.py +0 -0
  12. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/domain/context.py +0 -0
  13. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/domain/graph.py +0 -0
  14. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/domain/markdown.py +0 -0
  15. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/domain/paths.py +0 -0
  16. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/domain/policy.py +0 -0
  17. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/domain/settings.py +0 -0
  18. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/lint.py +0 -0
  19. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/mcp_tools.py +0 -0
  20. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/plugin.py +0 -0
  21. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/py.typed +0 -0
  22. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/rest.py +0 -0
  23. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/schema.py +0 -0
  24. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/katalyst_plugin_context/service.py +0 -0
  25. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/src/layer.yaml +0 -0
  26. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/tests/conftest.py +0 -0
  27. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/tests/context_support.py +0 -0
  28. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/tests/test_context.py +0 -0
  29. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/tests/test_lint.py +0 -0
  30. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/tests/test_shared_names.py +0 -0
  31. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/tests/test_surfaces.py +0 -0
  32. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/.gitignore +0 -0
  33. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/Justfile +0 -0
  34. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/features/cli/52_kata_context.feature +0 -0
  35. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/features/cli/53_kata_context_budget_policy.feature +0 -0
  36. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/features/mcp/09_mcp_context.feature +0 -0
  37. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/areas/payments.yaml +0 -0
  38. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/checks/encrypt-card-data.scan-for-card-numbers.yaml +0 -0
  39. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/components/payments.checkout.web-api.yaml +0 -0
  40. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/decisions/async-events.yaml +0 -0
  41. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/decisions/pin-httpx.yaml +0 -0
  42. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/decisions/use-postgres.yaml +0 -0
  43. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/packages/httpx.yaml +0 -0
  44. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/rules/encrypt-card-data.yaml +0 -0
  45. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/rules/log-correlation-id.yaml +0 -0
  46. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/fixtures/context-catalogue/catalogue/services/payments.checkout.yaml +0 -0
  47. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/layer.yaml +0 -0
  48. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/package.json +0 -0
  49. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/playwright.config.ts +0 -0
  50. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/steps/context.ts +0 -0
  51. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/steps/steps.ts +0 -0
  52. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a12}/xspec/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: katalyst-plugin-context
3
- Version: 0.2.1a11
3
+ Version: 0.2.1a12
4
4
  Summary: Katalyst context plugin — the right context from the taxonomy for an agent working on a node
5
5
  Author: eSimplicity
6
6
  License: Proprietary
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-plugin-context"
3
- version = "0.2.1a11"
3
+ version = "0.2.1a12"
4
4
  description = "Katalyst context plugin — the right context from the taxonomy for an agent working on a node"
5
5
  license = { text = "Proprietary" }
6
6
  requires-python = ">=3.13,<3.14"
@@ -6,12 +6,23 @@ the profile's filters (:func:`katalyst_taxonomy.services.filters.visible_nodes`)
6
6
  kept only when both its ends are visible. A reference into another backend is stored as
7
7
  ``backend:fqtn`` and keyed that way, so an edge held by one backend can reach a node of
8
8
  another.
9
+
10
+ Cross-backend edges (#418)
11
+ --------------------------
12
+ A backend's ``list_relationships`` resolves references against *its own* documents only, so a
13
+ reference with a ``backend:`` prefix that names a node in another backend silently produces no
14
+ edge. After collecting per-backend edges, :func:`build_graph` runs a second pass that
15
+ resolves declared references (``FieldRef``) over the aggregated document set — the same set
16
+ lint uses — and emits every edge a backend could not emit on its own. The resolver is
17
+ ``engine.references.resolve_cross_references``, so the graph and the lint gate read
18
+ identical outcomes.
9
19
  """
10
20
 
11
21
  from __future__ import annotations
12
22
 
23
+ import logging
13
24
  from collections.abc import Callable, Iterable, Sequence
14
- from typing import Any, cast
25
+ from typing import TYPE_CHECKING, Any, cast
15
26
 
16
27
  from katalyst_engine.model.node import Node
17
28
  from katalyst_taxonomy.engine.accessors import (
@@ -19,14 +30,23 @@ from katalyst_taxonomy.engine.accessors import (
19
30
  get_markdown_body,
20
31
  get_node_backend,
21
32
  )
22
- from katalyst_taxonomy.engine.identity import split_backend_prefix
33
+ from katalyst_taxonomy.engine.identity import (
34
+ split_backend_prefix,
35
+ with_backend_prefix,
36
+ )
23
37
  from katalyst_taxonomy.engine.qualification import split_kind_selector
38
+ from katalyst_taxonomy.engine.references import resolve_cross_references
24
39
  from katalyst_taxonomy.ports.backend import TaxonomyBackend
25
40
 
26
41
  from katalyst_plugin_context.domain.graph import Edge, Graph, GraphNode
27
42
 
43
+ if TYPE_CHECKING: # pragma: no cover - typing only
44
+ from katalyst_engine.schema.registry import SchemaRegistry
45
+
28
46
  __all__ = ["Carriers", "build_graph", "graph_from"]
29
47
 
48
+ logger = logging.getLogger(__name__)
49
+
30
50
  #: What a kind can carry: the relationship types it holds, each with the kinds it may point at
31
51
  #: (empty: any kind).
32
52
  Carriers = Callable[[str], Sequence[tuple[str, frozenset[str]]]]
@@ -136,14 +156,92 @@ def _of_kind(kinds: list[str], address: str) -> list[str]:
136
156
  return kinds if selected is None else [kind for kind in kinds if kind == selected]
137
157
 
138
158
 
159
+ # ---------------------------------------------------------------------------
160
+ # Cross-backend edge stitching (#418)
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ #: An edge by the addresses of its ends: ``((backend, fqtn), rel_type, (backend, fqtn))``.
165
+ EdgeKey = tuple[tuple[str, str], str, tuple[str, str]]
166
+
167
+
168
+ def _edge_key(holder: str, source: str, rel_type: str, target: str) -> EdgeKey:
169
+ """*source* and *target* as their backend and qualified name, *holder* filling in a
170
+ backend an address does not name."""
171
+ return (_address(holder, source), rel_type, _address(holder, target))
172
+
173
+
174
+ def _stitch_cross_backend_edges(
175
+ registry: SchemaRegistry,
176
+ loaded: Sequence[tuple[str, Node]],
177
+ existing: set[EdgeKey],
178
+ ) -> list[tuple[str, str, str, str]]:
179
+ """Emit edges that per-backend resolution missed because the target is elsewhere.
180
+
181
+ Each backend builds its reference index from its own documents only, so a value
182
+ like ``ric:ric-testing`` (target in the ``ric`` backend) produces no edge when
183
+ resolved inside the ``golden`` backend. This function resolves every declared
184
+ ``FieldRef`` over the **aggregated** document set — the same corpus lint uses —
185
+ and returns the edges no backend emitted.
186
+
187
+ *existing* holds the edges the backends already produced, keyed by the addresses of
188
+ their ends (:func:`_edge_key`), so an edge is never duplicated and two backends'
189
+ edges between nodes of the same names are never mistaken for each other.
190
+
191
+ Each returned tuple is ``(holder_backend, source, rel_type, target)``, both ends
192
+ prefixed with their backend.
193
+ """
194
+ all_docs = tuple(doc for _, doc in loaded)
195
+ holder_of = {id(doc): holder for holder, doc in loaded}
196
+
197
+ def fqtn_of(doc: Node) -> str:
198
+ return doc.fqn or doc.name
199
+
200
+ def address_of(doc: Node) -> tuple[str, str]:
201
+ # A document without a backend stamp belongs to the backend that held it.
202
+ return (get_node_backend(doc) or holder_of.get(id(doc), ""), fqtn_of(doc))
203
+
204
+ extra: list[tuple[str, str, str, str]] = []
205
+ for resolution in resolve_cross_references(registry, all_docs, fqtn_of=fqtn_of):
206
+ target = resolution.target
207
+ rel_type = resolution.rel_type
208
+ if target is None or not rel_type:
209
+ continue
210
+
211
+ source_address = address_of(resolution.source)
212
+ target_address = address_of(target)
213
+ key: EdgeKey = (source_address, rel_type, target_address)
214
+ if key in existing:
215
+ continue
216
+
217
+ source_backend = source_address[0]
218
+ extra.append(
219
+ (
220
+ source_backend,
221
+ with_backend_prefix(source_backend, source_address[1]),
222
+ rel_type,
223
+ with_backend_prefix(target_address[0], target_address[1]),
224
+ )
225
+ )
226
+ existing.add(key)
227
+
228
+ if extra:
229
+ logger.debug("Cross-backend stitching added %d edge(s)", len(extra))
230
+ return extra
231
+
232
+
139
233
  def build_graph(
140
234
  sources: Iterable[tuple[str, TaxonomyBackend]],
141
235
  *,
142
236
  profile: str | None = None,
143
237
  carriers: Carriers | None = None,
238
+ registry: SchemaRegistry | None = None,
144
239
  ) -> Graph:
145
240
  """Read every backend in *sources* once and build the graph the profile may see.
146
241
 
242
+ When *registry* is supplied, a second pass resolves cross-backend references that
243
+ no single backend could resolve on its own (#418).
244
+
147
245
  Raises:
148
246
  PermissionDeniedError: If the profile may not read a backend (as every read does).
149
247
  """
@@ -157,6 +255,12 @@ def build_graph(
157
255
  (name, rel.source_fqtn, rel.rel_type, rel.target_fqtn)
158
256
  for rel in backend.list_relationships()
159
257
  )
258
+
259
+ # --- Cross-backend edge stitching (#418) ---
260
+ if registry is not None and len({name for name, _ in loaded}) > 1:
261
+ existing = {_edge_key(*rel) for rel in relationships}
262
+ relationships.extend(_stitch_cross_backend_edges(registry, loaded, existing))
263
+
160
264
  visible = {id(doc) for doc in visible_nodes(None, [doc for _, doc in loaded], profile=profile)}
161
265
  hidden = (
162
266
  (get_node_backend(doc) or holder, doc.fqn or doc.name)
@@ -43,8 +43,12 @@ def service_for(
43
43
  *,
44
44
  profile: str | None,
45
45
  ) -> ContextService:
46
- """The service over what *sources* hold, under the profile named *profile*."""
46
+ """The service over what *sources* hold, under the profile named *profile*.
47
+
48
+ The *registry* is forwarded to :func:`build_graph` so cross-backend references
49
+ can be resolved over the aggregated document set (#418).
50
+ """
47
51
  config, active = plugin_config(profile)
48
52
  schema = RegistrySchema(registry)
49
- graph = build_graph(sources, profile=active, carriers=schema.carried)
53
+ graph = build_graph(sources, profile=active, carriers=schema.carried, registry=registry)
50
54
  return ContextService(registry, graph, config=config, profile=active)
@@ -0,0 +1,287 @@
1
+ # pyright: reportPrivateUsage=false
2
+ """Tests for cross-backend edge stitching (#418).
3
+
4
+ When multiple backends contribute documents, a reference like ``beta:d-cross``
5
+ (target in the ``beta`` backend) silently produces no edge when resolved inside the
6
+ ``alpha`` backend, because each backend resolves references against its own documents
7
+ only. The stitching pass in :func:`build_graph` resolves every declared
8
+ ``FieldRef`` over the aggregated document set and emits the missing edges.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+ from unittest.mock import MagicMock
15
+
16
+ from context_support import node, registry
17
+ from katalyst_engine.model.node import Node
18
+ from katalyst_plugin_context.adapters.runtime_graph import (
19
+ _edge_key,
20
+ _stitch_cross_backend_edges,
21
+ build_graph,
22
+ )
23
+ from katalyst_plugin_context.domain.graph import Graph
24
+ from katalyst_taxonomy.engine.accessors import set_node_backend
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def _stamped(n: Node, backend: str) -> Node:
32
+ """Return *n* stamped as owned by *backend*."""
33
+ return set_node_backend(n, backend)
34
+
35
+
36
+ def _all_edges(g: Graph) -> list[tuple[str, str, str]]:
37
+ """Collect every edge in a graph as (source, rel, target) triples."""
38
+ seen: set[tuple[str, str, str]] = set()
39
+ for key in g.nodes:
40
+ for neighbour in g.neighbours(key):
41
+ if neighbour.direction == "out":
42
+ triple = (key, neighbour.rel, neighbour.other)
43
+ else:
44
+ triple = (neighbour.other, neighbour.rel, key)
45
+ seen.add(triple)
46
+ return sorted(seen)
47
+
48
+
49
+ def _two_backend_corpus() -> tuple[list[tuple[str, Node]], list[tuple[str, Node]]]:
50
+ """A corpus split across backends ``alpha`` and ``beta``.
51
+
52
+ alpha has:
53
+ region "north" with decisions=["beta:d-cross"] (target in beta)
54
+ zone "east" under north
55
+
56
+ beta has:
57
+ decision "d-cross" (the target alpha references)
58
+ decision "d-local" (unreferenced)
59
+ """
60
+ north = _stamped(
61
+ node("region", "north", decisions=["beta:d-cross"], rules=[]),
62
+ "alpha",
63
+ )
64
+ east = _stamped(
65
+ node("zone", "east", "north", parents={"node": "north"}, decisions=[], rules=[]),
66
+ "alpha",
67
+ )
68
+ d_cross = _stamped(
69
+ node("decision", "d-cross", title="Cross-backend decision", status="accepted"),
70
+ "beta",
71
+ )
72
+ d_local = _stamped(
73
+ node("decision", "d-local", title="Local decision", status="accepted"),
74
+ "beta",
75
+ )
76
+ alpha_docs: list[tuple[str, Node]] = [("alpha", north), ("alpha", east)]
77
+ beta_docs: list[tuple[str, Node]] = [("beta", d_cross), ("beta", d_local)]
78
+ return alpha_docs, beta_docs
79
+
80
+
81
+ def _make_backend(
82
+ docs: list[tuple[str, Node]], rels: list[tuple[str, str, str]] | None = None
83
+ ) -> Any:
84
+ """A mock TaxonomyBackend that returns the given documents and relationships."""
85
+ backend: Any = MagicMock()
86
+ backend.load_documents.return_value = [doc for _, doc in docs]
87
+ if rels:
88
+ rel_objects: list[Any] = []
89
+ for src, rel, tgt in rels:
90
+ r: Any = MagicMock()
91
+ r.source_fqtn = src
92
+ r.rel_type = rel
93
+ r.target_fqtn = tgt
94
+ rel_objects.append(r)
95
+ backend.list_relationships.return_value = rel_objects
96
+ else:
97
+ backend.list_relationships.return_value = []
98
+ return backend
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # _stitch_cross_backend_edges
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ class TestStitchCrossBackendEdges:
107
+ """Unit tests for the stitching function itself."""
108
+
109
+ def test_emits_edge_for_cross_backend_reference(self) -> None:
110
+ """A reference from alpha to beta:d-cross produces an edge."""
111
+ alpha_docs, beta_docs = _two_backend_corpus()
112
+ loaded = [*alpha_docs, *beta_docs]
113
+ reg = registry()
114
+
115
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=set())
116
+
117
+ cross_edges = [(src, rel, tgt) for _, src, rel, tgt in extra if "d-cross" in tgt]
118
+ assert len(cross_edges) >= 1, f"Expected cross-backend edge to d-cross, got {extra}"
119
+ assert any(rel == "GOVERNED_BY" for _, rel, _ in cross_edges)
120
+
121
+ def test_does_not_duplicate_existing_edges(self) -> None:
122
+ """Edges the backends already produced are not emitted again."""
123
+ alpha_docs, beta_docs = _two_backend_corpus()
124
+ loaded = [*alpha_docs, *beta_docs]
125
+ reg = registry()
126
+
127
+ extra_first = _stitch_cross_backend_edges(reg, loaded, existing=set())
128
+ assert len(extra_first) > 0, "Precondition: should have found cross-backend edges"
129
+
130
+ existing = {_edge_key(*edge) for edge in extra_first}
131
+ extra_second = _stitch_cross_backend_edges(reg, loaded, existing=existing)
132
+ assert extra_second == [], f"Should not duplicate, got {extra_second}"
133
+
134
+ def test_no_edges_when_all_references_are_same_backend(self) -> None:
135
+ """When all references resolve within the same backend, nothing new is emitted."""
136
+ from context_support import DOCUMENTS, EDGES
137
+
138
+ loaded: list[tuple[str, Node]] = [("repo", doc) for doc in DOCUMENTS]
139
+ reg = registry()
140
+
141
+ existing = {_edge_key("repo", src, rel, tgt) for src, rel, tgt in EDGES}
142
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=existing)
143
+ assert extra == []
144
+
145
+ def test_skips_unresolvable_references(self) -> None:
146
+ """A reference to a node that doesn't exist in any backend produces no edge."""
147
+ north = _stamped(
148
+ node("region", "north", decisions=["beta:nonexistent"], rules=[]),
149
+ "alpha",
150
+ )
151
+ d_other = _stamped(
152
+ node("decision", "d-other", title="Other"),
153
+ "beta",
154
+ )
155
+ loaded: list[tuple[str, Node]] = [("alpha", north), ("beta", d_other)]
156
+ reg = registry()
157
+
158
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=set())
159
+ assert extra == []
160
+
161
+ def test_edge_carries_correct_backend_prefixes(self) -> None:
162
+ """Emitted edges prefix both source and target with their backend."""
163
+ alpha_docs, beta_docs = _two_backend_corpus()
164
+ loaded = [*alpha_docs, *beta_docs]
165
+ reg = registry()
166
+
167
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=set())
168
+
169
+ for _holder, source, _rel, target in extra:
170
+ if "d-cross" in target:
171
+ assert "beta:" in target, f"Target should be beta-prefixed: {target}"
172
+ assert "alpha:" in source, f"Source should be alpha-prefixed: {source}"
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # build_graph integration
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class TestBuildGraphCrossBackend:
181
+ """Integration tests: build_graph with a registry resolves cross-backend edges."""
182
+
183
+ def test_cross_backend_edges_appear_in_graph(self) -> None:
184
+ """build_graph with registry stitches cross-backend edges into the result."""
185
+ alpha_docs, beta_docs = _two_backend_corpus()
186
+ reg = registry()
187
+
188
+ g = build_graph(
189
+ [("alpha", _make_backend(alpha_docs)), ("beta", _make_backend(beta_docs))],
190
+ registry=reg,
191
+ )
192
+
193
+ edges = _all_edges(g)
194
+ cross_edges = [(s, r, t) for s, r, t in edges if "d-cross" in t]
195
+ assert len(cross_edges) >= 1, f"Expected cross-backend edge to d-cross. All edges: {edges}"
196
+
197
+ def test_no_stitching_without_registry(self) -> None:
198
+ """Without a registry, build_graph does not attempt stitching."""
199
+ alpha_docs, beta_docs = _two_backend_corpus()
200
+
201
+ g = build_graph(
202
+ [("alpha", _make_backend(alpha_docs)), ("beta", _make_backend(beta_docs))],
203
+ registry=None,
204
+ )
205
+
206
+ edges = _all_edges(g)
207
+ cross_edges = [(s, r, t) for s, r, t in edges if "d-cross" in t]
208
+ assert cross_edges == [], (
209
+ f"Without registry, should not have cross-backend edges. Edges: {edges}"
210
+ )
211
+
212
+ def test_no_stitching_with_single_backend(self) -> None:
213
+ """With only one backend, stitching is skipped even with a registry."""
214
+ alpha_docs, _ = _two_backend_corpus()
215
+ reg = registry()
216
+
217
+ g = build_graph(
218
+ [("alpha", _make_backend(alpha_docs))],
219
+ registry=reg,
220
+ )
221
+
222
+ edges = _all_edges(g)
223
+ cross_edges = [(s, r, t) for s, r, t in edges if "d-cross" in t]
224
+ assert cross_edges == []
225
+
226
+ def test_existing_same_backend_edges_preserved(self) -> None:
227
+ """Edges within a single backend still work after stitching is added."""
228
+ alpha_docs, beta_docs = _two_backend_corpus()
229
+ reg = registry()
230
+
231
+ alpha_rels = [("north.east", "CHILD_OF", "north")]
232
+ g = build_graph(
233
+ [
234
+ ("alpha", _make_backend(alpha_docs, alpha_rels)),
235
+ ("beta", _make_backend(beta_docs)),
236
+ ],
237
+ registry=reg,
238
+ )
239
+
240
+ edges = _all_edges(g)
241
+ child_edges = [(s, r, t) for s, r, t in edges if r == "CHILD_OF"]
242
+ assert len(child_edges) >= 1, f"Expected CHILD_OF edge to survive. All edges: {edges}"
243
+
244
+ def test_a_same_named_edge_in_another_backend_does_not_hide_a_cross_backend_one(
245
+ self,
246
+ ) -> None:
247
+ """foe's own x -> y edge and ric's x -> foe:y are two edges, both kept."""
248
+ foe_x = _stamped(node("region", "x", decisions=["y"], rules=[]), "foe")
249
+ foe_y = _stamped(node("decision", "y", title="Y", status="accepted"), "foe")
250
+ ric_x = _stamped(node("region", "x", decisions=["foe:y"], rules=[]), "ric")
251
+ g = build_graph(
252
+ [
253
+ (
254
+ "foe",
255
+ _make_backend([("foe", foe_x), ("foe", foe_y)], [("x", "GOVERNED_BY", "y")]),
256
+ ),
257
+ ("ric", _make_backend([("ric", ric_x)])),
258
+ ],
259
+ registry=registry(),
260
+ )
261
+
262
+ edges = _all_edges(g)
263
+ assert ("foe:x", "GOVERNED_BY", "foe:y") in edges, edges
264
+ assert ("ric:x", "GOVERNED_BY", "foe:y") in edges, edges
265
+
266
+
267
+ class TestUnstampedDocuments:
268
+ """A document with no backend stamp belongs to the backend that held it."""
269
+
270
+ def test_an_unstamped_source_is_stitched_under_its_holder(self) -> None:
271
+ ric_x = node("region", "x", decisions=["foe:y"], rules=[])
272
+ foe_y = _stamped(node("decision", "y", title="Y", status="accepted"), "foe")
273
+ loaded: list[tuple[str, Node]] = [("ric", ric_x), ("foe", foe_y)]
274
+
275
+ extra = _stitch_cross_backend_edges(registry(), loaded, existing=set())
276
+
277
+ assert ("ric", "ric:x", "GOVERNED_BY", "foe:y") in extra, extra
278
+
279
+ def test_an_unstamped_source_gets_its_edge_in_the_graph(self) -> None:
280
+ ric_x = node("region", "x", decisions=["foe:y"], rules=[])
281
+ foe_y = _stamped(node("decision", "y", title="Y", status="accepted"), "foe")
282
+ g = build_graph(
283
+ [("ric", _make_backend([("ric", ric_x)])), ("foe", _make_backend([("foe", foe_y)]))],
284
+ registry=registry(),
285
+ )
286
+
287
+ assert ("ric:x", "GOVERNED_BY", "foe:y") in _all_edges(g)