From 1d15eca866371fc05adfd257d52d7b3ca5709940 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 5 Mar 2026 11:34:36 +0000 Subject: [PATCH] feat(acms): implement depth/breadth projection system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Depth/Breadth Projection System and Skeleton Context Propagation as specified in docs/specification.md §25265-25340 and §43057-43128: - ProjectionSpec: frozen Pydantic model capturing a projection request (focus, breadth, depth, gradient, domain) - ProjectedNode: frozen model for materialized graph nodes with resolved depth and distance - DepthBreadthProjector: stateless BFS projector over UKO graph adjacency with depth gradient (linear reduction by distance) - PlanContextInheritance: service computing child plan context from parent assembled context with skeleton injection - ChildContextResult: frozen result model with request and skeleton - InheritanceConfig: frozen config for skeleton_ratio (default 0.2) - Built-in DetailLevelMap presets for code, docs, and database Includes 27 Behave BDD scenarios, 9 Robot Framework integration tests, and ASV benchmarks for all components. ISSUES CLOSED: #544 --- benchmarks/depth_breadth_projection_bench.py | 120 ++++ features/depth_breadth_projection.feature | 251 +++++++ .../steps/depth_breadth_projection_steps.py | 648 ++++++++++++++++++ robot/depth_breadth_projection.robot | 49 ++ robot/helper_depth_breadth_projection.py | 116 ++++ .../services/depth_breadth_projection.py | 626 +++++++++++++++++ 6 files changed, 1810 insertions(+) create mode 100644 benchmarks/depth_breadth_projection_bench.py create mode 100644 features/depth_breadth_projection.feature create mode 100644 features/steps/depth_breadth_projection_steps.py create mode 100644 robot/depth_breadth_projection.robot create mode 100644 robot/helper_depth_breadth_projection.py create mode 100644 src/cleveragents/application/services/depth_breadth_projection.py diff --git a/benchmarks/depth_breadth_projection_bench.py b/benchmarks/depth_breadth_projection_bench.py new file mode 100644 index 000000000..121d75f3d --- /dev/null +++ b/benchmarks/depth_breadth_projection_bench.py @@ -0,0 +1,120 @@ +"""ASV benchmarks for depth/breadth projection system. + +Measures latency of projection, gradient computation, and +skeleton context inheritance. +""" + +from __future__ import annotations + +from cleveragents.application.services.depth_breadth_projection import ( + DepthBreadthProjector, + PlanContextInheritance, + ProjectedNode, + ProjectionSpec, + code_detail_map, +) +from cleveragents.domain.models.acms.crp import ( + ContextFragment, + FragmentProvenance, +) + +_PROV = FragmentProvenance(resource_uri="bench://projection") + + +def _make_frag(i: int, depth: int = 3) -> ContextFragment: + return ContextFragment( + uko_node=f"bench://node_{i}", + content=f"node_{i}", + token_count=100, + relevance_score=0.8, + detail_depth=depth, + provenance=_PROV, + ) + + +class _FakeContext: + def __init__(self, n: int = 10) -> None: + self.fragments = [_make_frag(i) for i in range(n)] + self.context_hash = "benchhash" + + +def _build_linear_graph(n: int) -> dict[str, list[str]]: + """Build a linear graph: node_0 -> node_1 -> ... -> node_(n-1).""" + adj: dict[str, list[str]] = {} + for i in range(n - 1): + adj[f"node_{i}"] = [f"node_{i + 1}"] + return adj + + +class TimeProjectionSpec: + """Benchmark ProjectionSpec creation.""" + + def time_create_spec(self) -> None: + ProjectionSpec(focus=("class://Auth",), breadth=2, depth=9) + + def time_create_spec_named_depth(self) -> None: + ProjectionSpec(focus=("class://Auth",), depth="FULL_SOURCE") + + +class TimeProjectedNode: + """Benchmark ProjectedNode creation.""" + + def time_create_node(self) -> None: + ProjectedNode(uri="class://Foo", distance=1, resolved_depth=4) + + +class TimeProjector: + """Benchmark DepthBreadthProjector.""" + + def setup(self) -> None: + self.projector = DepthBreadthProjector() + self.projector.register_detail_map("uko-code:", code_detail_map()) + self.graph_10 = _build_linear_graph(10) + self.graph_100 = _build_linear_graph(100) + + def time_project_small_graph(self) -> None: + spec = ProjectionSpec(focus=("node_0",), breadth=5, depth=9) + self.projector.project(spec, self.graph_10) + + def time_project_medium_graph(self) -> None: + spec = ProjectionSpec(focus=("node_0",), breadth=10, depth=9) + self.projector.project(spec, self.graph_100) + + def time_project_no_gradient(self) -> None: + spec = ProjectionSpec( + focus=("node_0",), breadth=5, depth=9, depth_gradient=False + ) + self.projector.project(spec, self.graph_10) + + def time_project_to_fragments(self) -> None: + spec = ProjectionSpec(focus=("node_0",), breadth=5, depth=9) + self.projector.project_to_fragments(spec, self.graph_10) + + def time_project_named_depth(self) -> None: + spec = ProjectionSpec( + focus=("node_0",), + breadth=3, + depth="FULL_SOURCE", + domain="uko-code:", + ) + self.projector.project(spec, self.graph_10) + + +class TimeInheritance: + """Benchmark PlanContextInheritance.""" + + def setup(self) -> None: + self.service = PlanContextInheritance() + self.parent_ctx = _FakeContext(n=10) + + def time_compute_child_context(self) -> None: + self.service.compute_child_context( + parent_context=self.parent_ctx, + child_focus=["class://Child"], + child_token_budget=4096, + ) + + def time_extract_focus(self) -> None: + PlanContextInheritance.extract_child_focus( + ["class://A", "class://B", "class://C"] + ) diff --git a/features/depth_breadth_projection.feature b/features/depth_breadth_projection.feature new file mode 100644 index 000000000..dc1f56353 --- /dev/null +++ b/features/depth_breadth_projection.feature @@ -0,0 +1,251 @@ +@phase2 @acms @depth_breadth_projection +Feature: Depth/Breadth Projection System and Skeleton Context Propagation + As a CleverAgents developer + I want a projection system over the UKO graph + So that context can be materialized at varying detail levels + based on distance from focal entities, and child plans can + inherit compressed parent context via skeleton propagation + + # --------------------------------------------------------------------------- + # ProjectionSpec model + # --------------------------------------------------------------------------- + + @projection @model + Scenario: ProjectionSpec is a frozen Pydantic model + Given the depth/breadth projection modules are available + When I create a ProjectionSpec with focus "class://AuthManager" and breadth 2 and depth 9 + Then the projection spec focus should contain "class://AuthManager" + And the projection spec breadth should be 2 + And the projection spec depth should be 9 + And the projection spec depth_gradient should be True + And the projection spec domain should be "uko-code:" + And the projection spec should be immutable + + @projection @model + Scenario: ProjectionSpec rejects empty focus + Given the depth/breadth projection modules are available + When I create a ProjectionSpec with empty focus + Then a projection ValidationError should be raised + + @projection @model + Scenario: ProjectionSpec rejects negative integer depth + Given the depth/breadth projection modules are available + When I create a ProjectionSpec with negative depth + Then a projection ValueError should be raised mentioning "non-negative" + + @projection @model + Scenario: ProjectionSpec accepts named depth levels + Given the depth/breadth projection modules are available + When I create a ProjectionSpec with focus "module://auth" and named depth "FULL_SOURCE" + Then the projection spec depth should be "FULL_SOURCE" + + # --------------------------------------------------------------------------- + # ProjectedNode model + # --------------------------------------------------------------------------- + + @projection @model + Scenario: ProjectedNode is a frozen Pydantic model + Given the depth/breadth projection modules are available + When I create a ProjectedNode with uri "class://Foo" and distance 1 and resolved_depth 4 + Then the projected node uri should be "class://Foo" + And the projected node distance should be 1 + And the projected node resolved_depth should be 4 + And the projected node should be immutable + + # --------------------------------------------------------------------------- + # DepthBreadthProjector — BFS traversal + # --------------------------------------------------------------------------- + + @projection @projector + Scenario: Projector performs BFS from focus with breadth 0 + Given the depth/breadth projection modules are available + And a UKO graph with nodes A B C and edges A->B B->C + When I project with focus "A" and breadth 0 and depth 9 + Then the projection should contain exactly 1 node + And the projected nodes should include "A" at distance 0 + + @projection @projector + Scenario: Projector performs BFS from focus with breadth 1 + Given the depth/breadth projection modules are available + And a UKO graph with nodes A B C and edges A->B B->C + When I project with focus "A" and breadth 1 and depth 9 + Then the projection should contain exactly 2 nodes + And the projected nodes should include "A" at distance 0 + And the projected nodes should include "B" at distance 1 + + @projection @projector + Scenario: Projector performs BFS from focus with breadth 2 + Given the depth/breadth projection modules are available + And a UKO graph with nodes A B C and edges A->B B->C + When I project with focus "A" and breadth 2 and depth 9 + Then the projection should contain exactly 3 nodes + And the projected nodes should include "A" at distance 0 + And the projected nodes should include "B" at distance 1 + And the projected nodes should include "C" at distance 2 + + @projection @projector + Scenario: Projector applies depth gradient + Given the depth/breadth projection modules are available + And a UKO graph with nodes A B C and edges A->B B->C + When I project with focus "A" and breadth 2 and depth 9 and gradient True + Then the projected node "A" should have resolved_depth 9 + And the projected node "B" should have resolved_depth greater than 0 + And the projected node "B" should have resolved_depth less than 9 + And the projected node "C" should have resolved_depth 0 + + @projection @projector + Scenario: Projector without gradient assigns uniform depth + Given the depth/breadth projection modules are available + And a UKO graph with nodes A B C and edges A->B B->C + When I project with focus "A" and breadth 2 and depth 9 and gradient False + Then the projected node "A" should have resolved_depth 9 + And the projected node "B" should have resolved_depth 9 + And the projected node "C" should have resolved_depth 9 + + @projection @projector + Scenario: Projector resolves named depth via DetailLevelMap + Given the depth/breadth projection modules are available + And a code DetailLevelMap is registered for domain "uko-code:" + And a UKO graph with nodes A B and edges A->B + When I project with focus "A" and breadth 1 and named depth "FULL_SOURCE" in domain "uko-code:" + Then the projected node "A" should have resolved_depth 9 + And the projected node "B" should have resolved_depth 0 + + @projection @projector + Scenario: Projector raises error for unresolvable named depth + Given the depth/breadth projection modules are available + And a UKO graph with nodes A and no edges + When I project with focus "A" and breadth 0 and named depth "NONEXISTENT" in domain "unknown:" + Then a projection ValueError should be raised mentioning "no DetailLevelMap" + + @projection @projector + Scenario: Projector handles multiple focus nodes + Given the depth/breadth projection modules are available + And a UKO graph with nodes A B C D and edges A->B C->D + When I project with multi-focus "A" and "C" and breadth 1 and depth 5 + Then the projection should contain exactly 4 nodes + And the projected nodes should include "A" at distance 0 + And the projected nodes should include "C" at distance 0 + And the projected nodes should include "B" at distance 1 + And the projected nodes should include "D" at distance 1 + + @projection @projector + Scenario: Projector converts projection to ContextFragments + Given the depth/breadth projection modules are available + And a UKO graph with nodes A B and edges A->B + When I project to fragments with focus "A" and breadth 1 and depth 4 + Then the result should contain 2 ContextFragments + And the first fragment uko_node should be "A" + And all fragments should have metadata key "projected" set to True + + # --------------------------------------------------------------------------- + # Built-in DetailLevelMap presets + # --------------------------------------------------------------------------- + + @projection @presets + Scenario: Code detail level map has correct levels + Given the depth/breadth projection modules are available + When I create the code detail level map preset + Then the code map should resolve "MODULE_LISTING" to 0 + And the code map should resolve "SIGNATURES" to 4 + And the code map should resolve "FULL_SOURCE" to 9 + + @projection @presets + Scenario: Docs detail level map has correct levels + Given the depth/breadth projection modules are available + When I create the docs detail level map preset + Then the docs map should resolve "TITLE_ONLY" to 0 + And the docs map should resolve "FULL_CONTENT" to 10 + + @projection @presets + Scenario: Database detail level map has correct levels + Given the depth/breadth projection modules are available + When I create the database detail level map preset + Then the database map should resolve "TABLE_LISTING" to 0 + And the database map should resolve "FULL_CATALOG" to 11 + + # --------------------------------------------------------------------------- + # PlanContextInheritance — skeleton propagation + # --------------------------------------------------------------------------- + + @inheritance @skeleton + Scenario: PlanContextInheritance computes child context request + Given the depth/breadth projection modules are available + And a parent assembled context with 3 fragments at detail_depth 3 + And a PlanContextInheritance service with default config + When I compute child context with focus "class://Child" and budget 1000 + Then the child result should contain a ContextRequest + And the child request focus should contain "class://Child" + And the child request depth_gradient should be True + And the child result depth_delta should be 1 + + @inheritance @skeleton + Scenario: PlanContextInheritance increases child detail from parent + Given the depth/breadth projection modules are available + And a parent assembled context with 3 fragments at detail_depth 3 + And a PlanContextInheritance service with default config + When I compute child context with focus "class://Child" and budget 1000 + Then the child request depth should be greater than 3 + + @inheritance @skeleton + Scenario: PlanContextInheritance narrows child breadth + Given the depth/breadth projection modules are available + And a parent assembled context with 3 fragments at detail_depth 3 + And a PlanContextInheritance service with default config + When I compute child context with focus "class://Child" and budget 1000 at depth_in_tree 0 to 1 + Then the child request breadth should be at most 2 + + @inheritance @skeleton + Scenario: PlanContextInheritance injects skeleton fragments + Given the depth/breadth projection modules are available + And a parent assembled context with 3 fragments at detail_depth 3 + And a mock skeleton compressor that returns 2 fragments + And a PlanContextInheritance service with the mock compressor + When I compute child context with focus "class://Child" and budget 1000 + Then the child result skeleton_fragments should have 2 entries + + @inheritance @skeleton + Scenario: PlanContextInheritance skips skeleton when budget too small + Given the depth/breadth projection modules are available + And a parent assembled context with 3 fragments at detail_depth 3 + And a mock skeleton compressor that returns 2 fragments + And a PlanContextInheritance service with the mock compressor and min_skeleton_tokens 500 + When I compute child context with focus "class://Child" and budget 100 + Then the child result skeleton_fragments should have 0 entries + + @inheritance @skeleton + Scenario: PlanContextInheritance rejects invalid depth delta + Given the depth/breadth projection modules are available + And a parent assembled context with 3 fragments at detail_depth 3 + And a PlanContextInheritance service with default config + When I compute child context with parent_depth 2 and child_depth 1 + Then a projection ValueError should be raised mentioning "child_depth_in_tree" + + @inheritance @skeleton + Scenario: PlanContextInheritance config uses default skeleton_ratio + Given the depth/breadth projection modules are available + When I create an InheritanceConfig with defaults + Then the inheritance config skeleton_ratio should be 0.2 + + @inheritance @skeleton + Scenario: PlanContextInheritance extract_child_focus returns parent decisions + Given the depth/breadth projection modules are available + When I extract child focus from decisions "class://A" and "class://B" + Then the child focus should include both "class://A" and "class://B" + + @inheritance @skeleton + Scenario: PlanContextInheritance extract_child_focus uses fallback + Given the depth/breadth projection modules are available + When I extract child focus from empty decisions with fallback "class://Default" + Then the child focus should contain "class://Default" + + # --------------------------------------------------------------------------- + # ChildContextResult model + # --------------------------------------------------------------------------- + + @inheritance @model + Scenario: ChildContextResult is a frozen Pydantic model + Given the depth/breadth projection modules are available + When I create a ChildContextResult with a request and skeleton data + Then the child context result should be immutable + And the child context result should contain the request diff --git a/features/steps/depth_breadth_projection_steps.py b/features/steps/depth_breadth_projection_steps.py new file mode 100644 index 000000000..80285388a --- /dev/null +++ b/features/steps/depth_breadth_projection_steps.py @@ -0,0 +1,648 @@ +"""Step definitions for features/depth_breadth_projection.feature. + +Tests the Depth/Breadth Projection System and Skeleton Context Propagation: +ProjectionSpec, ProjectedNode, DepthBreadthProjector, PlanContextInheritance, +ChildContextResult, InheritanceConfig, and built-in DetailLevelMap presets. +""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.application.services.depth_breadth_projection import ( + ChildContextResult, + DepthBreadthProjector, + InheritanceConfig, + PlanContextInheritance, + ProjectedNode, + ProjectionSpec, + code_detail_map, + database_detail_map, + docs_detail_map, +) +from cleveragents.domain.models.acms.crp import ( + ContextFragment, + ContextRequest, + FragmentProvenance, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PROV = FragmentProvenance(resource_uri="test://projection") + + +def _make_frag( + uko_node: str = "test://node", + content: str = "test", + token_count: int = 100, + relevance_score: float = 0.8, + detail_depth: int = 3, +) -> ContextFragment: + return ContextFragment( + uko_node=uko_node, + content=content, + token_count=token_count, + relevance_score=relevance_score, + detail_depth=detail_depth, + provenance=_PROV, + ) + + +class _FakeAssembledContext: + """Minimal stand-in for AssembledContext.""" + + def __init__( + self, + fragments: list[ContextFragment], + context_hash: str = "abc123", + ) -> None: + self.fragments = fragments + self.context_hash = context_hash + + +class _MockSkeletonCompressor: + """Compressor that returns a fixed number of fragments.""" + + def __init__(self, n_fragments: int = 2) -> None: + self._n = n_fragments + + def compress( + self, + fragments: tuple[ContextFragment, ...], + skeleton_budget: int, + ) -> tuple[ContextFragment, ...]: + return tuple(fragments[: self._n]) + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given("the depth/breadth projection modules are available") +def step_modules_available(context: Context) -> None: + pass + + +@given("a UKO graph with nodes A B C and edges A->B B->C") +def step_graph_abc(context: Context) -> None: + context.adjacency = { + "A": ["B"], + "B": ["C"], + } + + +@given("a UKO graph with nodes A B and edges A->B") +def step_graph_ab(context: Context) -> None: + context.adjacency = { + "A": ["B"], + } + + +@given("a UKO graph with nodes A and no edges") +def step_graph_a_only(context: Context) -> None: + adj: dict[str, list[str]] = {} + context.adjacency = adj + + +@given("a UKO graph with nodes A B C D and edges A->B C->D") +def step_graph_abcd(context: Context) -> None: + context.adjacency = { + "A": ["B"], + "C": ["D"], + } + + +@given('a code DetailLevelMap is registered for domain "{domain}"') +def step_register_code_map(context: Context, domain: str) -> None: + projector = DepthBreadthProjector() + projector.register_detail_map(domain, code_detail_map()) + context.projector = projector + + +@given("a parent assembled context with {n:d} fragments at detail_depth {depth:d}") +def step_parent_context(context: Context, n: int, depth: int) -> None: + frags = [ + _make_frag( + uko_node=f"module://mod_{i}", + content=f"module_{i}", + token_count=100, + detail_depth=depth, + ) + for i in range(n) + ] + context.parent_context = _FakeAssembledContext(frags, context_hash="parenthash123") + + +@given("a PlanContextInheritance service with default config") +def step_inheritance_default(context: Context) -> None: + context.inheritance_service = PlanContextInheritance() + + +@given("a mock skeleton compressor that returns {n:d} fragments") +def step_mock_compressor(context: Context, n: int) -> None: + context.mock_compressor = _MockSkeletonCompressor(n_fragments=n) + + +@given("a PlanContextInheritance service with the mock compressor") +def step_inheritance_with_compressor(context: Context) -> None: + context.inheritance_service = PlanContextInheritance( + skeleton_compressor=context.mock_compressor, + ) + + +@given( + "a PlanContextInheritance service with the mock compressor and min_skeleton_tokens {min_tok:d}" +) +def step_inheritance_with_min_tokens(context: Context, min_tok: int) -> None: + config = InheritanceConfig(min_skeleton_tokens=min_tok) + context.inheritance_service = PlanContextInheritance( + config=config, + skeleton_compressor=context.mock_compressor, + ) + + +# --------------------------------------------------------------------------- +# When steps — ProjectionSpec +# --------------------------------------------------------------------------- + + +@when( + 'I create a ProjectionSpec with focus "{focus}" and breadth {breadth:d} and depth {depth:d}' +) +def step_create_spec(context: Context, focus: str, breadth: int, depth: int) -> None: + context.proj_spec = ProjectionSpec( + focus=(focus,), + breadth=breadth, + depth=depth, + ) + + +@when("I create a ProjectionSpec with empty focus") +def step_create_spec_empty_focus(context: Context) -> None: + context.proj_error = None + try: + context.proj_spec = ProjectionSpec(focus=(), breadth=2, depth=3) + except ValidationError as exc: + context.proj_error = exc + + +@when("I create a ProjectionSpec with negative depth") +def step_create_spec_negative_depth(context: Context) -> None: + context.proj_error = None + try: + context.proj_spec = ProjectionSpec(focus=("A",), breadth=2, depth=-1) + except (ValueError, ValidationError) as exc: + context.proj_error = exc + + +@when('I create a ProjectionSpec with focus "{focus}" and named depth "{depth}"') +def step_create_spec_named(context: Context, focus: str, depth: str) -> None: + context.proj_spec = ProjectionSpec( + focus=(focus,), + depth=depth, + ) + + +# --------------------------------------------------------------------------- +# When steps — ProjectedNode +# --------------------------------------------------------------------------- + + +@when( + 'I create a ProjectedNode with uri "{uri}" and distance {dist:d} and resolved_depth {depth:d}' +) +def step_create_projected_node( + context: Context, uri: str, dist: int, depth: int +) -> None: + context.proj_node = ProjectedNode(uri=uri, distance=dist, resolved_depth=depth) + + +# --------------------------------------------------------------------------- +# When steps — DepthBreadthProjector +# --------------------------------------------------------------------------- + + +@when('I project with focus "{focus}" and breadth {breadth:d} and depth {depth:d}') +def step_project(context: Context, focus: str, breadth: int, depth: int) -> None: + projector = getattr(context, "projector", None) or DepthBreadthProjector() + spec = ProjectionSpec(focus=(focus,), breadth=breadth, depth=depth) + context.proj_result = projector.project(spec, context.adjacency) + + +@when( + 'I project with focus "{focus}" and breadth {breadth:d} and depth {depth:d} and gradient {gradient}' +) +def step_project_gradient( + context: Context, focus: str, breadth: int, depth: int, gradient: str +) -> None: + grad = gradient.strip().lower() == "true" + projector = DepthBreadthProjector() + spec = ProjectionSpec( + focus=(focus,), breadth=breadth, depth=depth, depth_gradient=grad + ) + context.proj_result = projector.project(spec, context.adjacency) + + +@when( + 'I project with focus "{focus}" and breadth {breadth:d} and named depth "{depth}" in domain "{domain}"' +) +def step_project_named( + context: Context, focus: str, breadth: int, depth: str, domain: str +) -> None: + projector = getattr(context, "projector", None) or DepthBreadthProjector() + spec = ProjectionSpec(focus=(focus,), breadth=breadth, depth=depth, domain=domain) + context.proj_error = None + try: + context.proj_result = projector.project(spec, context.adjacency) + except ValueError as exc: + context.proj_error = exc + + +@when( + 'I project with multi-focus "{f1}" and "{f2}" and breadth {breadth:d} and depth {depth:d}' +) +def step_project_multi_focus( + context: Context, f1: str, f2: str, breadth: int, depth: int +) -> None: + projector = DepthBreadthProjector() + spec = ProjectionSpec(focus=(f1, f2), breadth=breadth, depth=depth) + context.proj_result = projector.project(spec, context.adjacency) + + +@when( + 'I project to fragments with focus "{focus}" and breadth {breadth:d} and depth {depth:d}' +) +def step_project_fragments( + context: Context, focus: str, breadth: int, depth: int +) -> None: + projector = DepthBreadthProjector() + spec = ProjectionSpec(focus=(focus,), breadth=breadth, depth=depth) + context.proj_fragments = projector.project_to_fragments(spec, context.adjacency) + + +# --------------------------------------------------------------------------- +# When steps — DetailLevelMap presets +# --------------------------------------------------------------------------- + + +@when("I create the code detail level map preset") +def step_code_map(context: Context) -> None: + context.code_map = code_detail_map() + + +@when("I create the docs detail level map preset") +def step_docs_map(context: Context) -> None: + context.docs_map = docs_detail_map() + + +@when("I create the database detail level map preset") +def step_db_map(context: Context) -> None: + context.db_map = database_detail_map() + + +# --------------------------------------------------------------------------- +# When steps — PlanContextInheritance +# --------------------------------------------------------------------------- + + +@when('I compute child context with focus "{focus}" and budget {budget:d}') +def step_compute_child(context: Context, focus: str, budget: int) -> None: + context.child_error = None + try: + context.child_result = context.inheritance_service.compute_child_context( + parent_context=context.parent_context, + child_focus=[focus], + child_token_budget=budget, + ) + except ValueError as exc: + context.child_error = exc + + +@when( + 'I compute child context with focus "{focus}" and budget {budget:d} at depth_in_tree {pd:d} to {cd:d}' +) +def step_compute_child_depths( + context: Context, focus: str, budget: int, pd: int, cd: int +) -> None: + context.child_result = context.inheritance_service.compute_child_context( + parent_context=context.parent_context, + child_focus=[focus], + child_token_budget=budget, + parent_depth_in_tree=pd, + child_depth_in_tree=cd, + ) + + +@when("I compute child context with parent_depth {pd:d} and child_depth {cd:d}") +def step_compute_child_invalid_depth(context: Context, pd: int, cd: int) -> None: + context.child_error = None + try: + context.child_result = context.inheritance_service.compute_child_context( + parent_context=context.parent_context, + child_focus=["class://X"], + child_token_budget=1000, + parent_depth_in_tree=pd, + child_depth_in_tree=cd, + ) + except ValueError as exc: + context.child_error = exc + + +@when("I create an InheritanceConfig with defaults") +def step_default_config(context: Context) -> None: + context.inheritance_config = InheritanceConfig() + + +@when('I extract child focus from decisions "{d1}" and "{d2}"') +def step_extract_focus(context: Context, d1: str, d2: str) -> None: + context.child_focus = PlanContextInheritance.extract_child_focus([d1, d2]) + + +@when('I extract child focus from empty decisions with fallback "{fallback}"') +def step_extract_focus_fallback(context: Context, fallback: str) -> None: + context.child_focus = PlanContextInheritance.extract_child_focus( + [], fallback_focus=[fallback] + ) + + +@when("I create a ChildContextResult with a request and skeleton data") +def step_create_child_result(context: Context) -> None: + req = ContextRequest(focus=["class://Test"], breadth=1, depth=5) + frag = _make_frag(uko_node="test://skel", content="skeleton", token_count=50) + context.child_ctx_result = ChildContextResult( + request=req, + skeleton_fragments=(frag,), + parent_context_hash="hash123", + skeleton_budget=200, + depth_delta=1, + ) + + +# --------------------------------------------------------------------------- +# Then steps — ProjectionSpec +# --------------------------------------------------------------------------- + + +@then('the projection spec focus should contain "{uri}"') +def step_spec_focus(context: Context, uri: str) -> None: + assert uri in context.proj_spec.focus, ( + f"Expected {uri!r} in focus {context.proj_spec.focus}" + ) + + +@then("the projection spec breadth should be {expected:d}") +def step_spec_breadth(context: Context, expected: int) -> None: + assert context.proj_spec.breadth == expected + + +@then("the projection spec depth should be {expected:d}") +def step_spec_depth_int(context: Context, expected: int) -> None: + assert context.proj_spec.depth == expected + + +@then('the projection spec depth should be "{expected}"') +def step_spec_depth_str(context: Context, expected: str) -> None: + assert context.proj_spec.depth == expected + + +@then("the projection spec depth_gradient should be True") +def step_spec_gradient_true(context: Context) -> None: + assert context.proj_spec.depth_gradient is True + + +@then('the projection spec domain should be "{expected}"') +def step_spec_domain(context: Context, expected: str) -> None: + assert context.proj_spec.domain == expected + + +@then("the projection spec should be immutable") +def step_spec_immutable(context: Context) -> None: + try: + context.proj_spec.breadth = 99 # type: ignore[misc] + assert False, "Should have raised" # noqa: B011 + except (ValidationError, TypeError, AttributeError): + pass + + +@then("a projection ValidationError should be raised") +def step_proj_validation_error(context: Context) -> None: + assert context.proj_error is not None, "Expected ValidationError but none raised" + assert isinstance(context.proj_error, ValidationError) + + +@then('a projection ValueError should be raised mentioning "{keyword}"') +def step_proj_value_error(context: Context, keyword: str) -> None: + error = getattr(context, "proj_error", None) or getattr( + context, "child_error", None + ) + assert error is not None, f"Expected ValueError mentioning {keyword!r}" + assert keyword.lower() in str(error).lower(), f"Expected '{keyword}' in: {error}" + + +# --------------------------------------------------------------------------- +# Then steps — ProjectedNode +# --------------------------------------------------------------------------- + + +@then('the projected node uri should be "{expected}"') +def step_node_uri(context: Context, expected: str) -> None: + assert context.proj_node.uri == expected + + +@then("the projected node distance should be {expected:d}") +def step_node_distance(context: Context, expected: int) -> None: + assert context.proj_node.distance == expected + + +@then("the projected node resolved_depth should be {expected:d}") +def step_node_resolved_depth(context: Context, expected: int) -> None: + assert context.proj_node.resolved_depth == expected + + +@then("the projected node should be immutable") +def step_node_immutable(context: Context) -> None: + try: + context.proj_node.distance = 99 # type: ignore[misc] + assert False, "Should have raised" # noqa: B011 + except (ValidationError, TypeError, AttributeError): + pass + + +# --------------------------------------------------------------------------- +# Then steps — DepthBreadthProjector +# --------------------------------------------------------------------------- + + +@then("the projection should contain exactly {count:d} node") +def step_proj_count_singular(context: Context, count: int) -> None: + assert len(context.proj_result) == count, ( + f"Expected {count} nodes, got {len(context.proj_result)}" + ) + + +@then("the projection should contain exactly {count:d} nodes") +def step_proj_count(context: Context, count: int) -> None: + assert len(context.proj_result) == count, ( + f"Expected {count} nodes, got {len(context.proj_result)}" + ) + + +@then('the projected nodes should include "{uri}" at distance {dist:d}') +def step_proj_node_at_distance(context: Context, uri: str, dist: int) -> None: + found = [n for n in context.proj_result if n.uri == uri] + assert found, f"Node {uri!r} not found in projection" + assert found[0].distance == dist, ( + f"Expected {uri!r} at distance {dist}, got {found[0].distance}" + ) + + +@then('the projected node "{uri}" should have resolved_depth {depth:d}') +def step_proj_node_depth(context: Context, uri: str, depth: int) -> None: + found = [n for n in context.proj_result if n.uri == uri] + assert found, f"Node {uri!r} not found" + assert found[0].resolved_depth == depth, ( + f"Expected {uri!r} depth={depth}, got {found[0].resolved_depth}" + ) + + +@then('the projected node "{uri}" should have resolved_depth greater than {depth:d}') +def step_proj_node_depth_gt(context: Context, uri: str, depth: int) -> None: + found = [n for n in context.proj_result if n.uri == uri] + assert found, f"Node {uri!r} not found" + assert found[0].resolved_depth > depth, ( + f"Expected {uri!r} depth > {depth}, got {found[0].resolved_depth}" + ) + + +@then('the projected node "{uri}" should have resolved_depth less than {depth:d}') +def step_proj_node_depth_lt(context: Context, uri: str, depth: int) -> None: + found = [n for n in context.proj_result if n.uri == uri] + assert found, f"Node {uri!r} not found" + assert found[0].resolved_depth < depth, ( + f"Expected {uri!r} depth < {depth}, got {found[0].resolved_depth}" + ) + + +@then("the result should contain {count:d} ContextFragments") +def step_fragment_count(context: Context, count: int) -> None: + assert len(context.proj_fragments) == count + + +@then('the first fragment uko_node should be "{expected}"') +def step_first_frag_uko(context: Context, expected: str) -> None: + assert context.proj_fragments[0].uko_node == expected + + +@then('all fragments should have metadata key "{key}" set to True') +def step_fragments_metadata(context: Context, key: str) -> None: + for frag in context.proj_fragments: + assert frag.metadata.get(key) is True, ( + f"Fragment {frag.uko_node} missing metadata key {key!r}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — DetailLevelMap presets +# --------------------------------------------------------------------------- + + +@then('the code map should resolve "{level}" to {expected:d}') +def step_code_resolve(context: Context, level: str, expected: int) -> None: + assert context.code_map.resolve(level) == expected + + +@then('the docs map should resolve "{level}" to {expected:d}') +def step_docs_resolve(context: Context, level: str, expected: int) -> None: + assert context.docs_map.resolve(level) == expected + + +@then('the database map should resolve "{level}" to {expected:d}') +def step_db_resolve(context: Context, level: str, expected: int) -> None: + assert context.db_map.resolve(level) == expected + + +# --------------------------------------------------------------------------- +# Then steps — PlanContextInheritance +# --------------------------------------------------------------------------- + + +@then("the child result should contain a ContextRequest") +def step_child_has_request(context: Context) -> None: + assert hasattr(context.child_result, "request"), "No request in result" + assert isinstance(context.child_result.request, ContextRequest) + + +@then('the child request focus should contain "{uri}"') +def step_child_focus_contains(context: Context, uri: str) -> None: + assert uri in context.child_result.request.focus + + +@then("the child request depth_gradient should be True") +def step_child_gradient(context: Context) -> None: + assert context.child_result.request.depth_gradient is True + + +@then("the child result depth_delta should be {expected:d}") +def step_child_depth_delta(context: Context, expected: int) -> None: + assert context.child_result.depth_delta == expected + + +@then("the child request depth should be greater than {depth:d}") +def step_child_depth_gt(context: Context, depth: int) -> None: + assert context.child_result.request.depth > depth, ( + f"Expected depth > {depth}, got {context.child_result.request.depth}" + ) + + +@then("the child request breadth should be at most {max_breadth:d}") +def step_child_breadth_max(context: Context, max_breadth: int) -> None: + assert context.child_result.request.breadth <= max_breadth, ( + f"Expected breadth <= {max_breadth}, got {context.child_result.request.breadth}" + ) + + +@then("the child result skeleton_fragments should have {count:d} entries") +def step_child_skeleton_count(context: Context, count: int) -> None: + assert len(context.child_result.skeleton_fragments) == count, ( + f"Expected {count} skeleton fragments, got {len(context.child_result.skeleton_fragments)}" + ) + + +@then("the inheritance config skeleton_ratio should be {expected:g}") +def step_config_ratio(context: Context, expected: float) -> None: + assert context.inheritance_config.skeleton_ratio == expected + + +@then('the child focus should contain "{uri}"') +def step_focus_contains_single(context: Context, uri: str) -> None: + assert uri in context.child_focus + + +@then('the child focus should include both "{uri1}" and "{uri2}"') +def step_focus_contains_both(context: Context, uri1: str, uri2: str) -> None: + assert uri1 in context.child_focus + assert uri2 in context.child_focus + + +# --------------------------------------------------------------------------- +# Then steps — ChildContextResult +# --------------------------------------------------------------------------- + + +@then("the child context result should be immutable") +def step_result_immutable(context: Context) -> None: + try: + context.child_ctx_result.depth_delta = 99 # type: ignore[misc] + assert False, "Should have raised" # noqa: B011 + except (ValidationError, TypeError, AttributeError): + pass + + +@then("the child context result should contain the request") +def step_result_has_request(context: Context) -> None: + assert isinstance(context.child_ctx_result.request, ContextRequest) diff --git a/robot/depth_breadth_projection.robot b/robot/depth_breadth_projection.robot new file mode 100644 index 000000000..a3c5ffec9 --- /dev/null +++ b/robot/depth_breadth_projection.robot @@ -0,0 +1,49 @@ +*** Settings *** +Documentation Integration tests for depth/breadth projection system +Library helper_depth_breadth_projection.py + +*** Test Cases *** +ProjectionSpec Can Be Created + [Documentation] Verify ProjectionSpec model creation + ${result}= Create Projection Spec class://Auth 2 9 + Should Be True ${result} + +ProjectedNode Can Be Created + [Documentation] Verify ProjectedNode model creation + ${result}= Create Projected Node class://Foo 1 4 + Should Be True ${result} + +Projector Performs BFS Traversal + [Documentation] DepthBreadthProjector BFS from focus + ${count}= Project And Count Nodes A 2 9 + Should Be Equal As Integers ${count} 3 + +Projector Applies Depth Gradient + [Documentation] Verify gradient reduces depth at distance + ${result}= Project With Gradient A 2 9 + Should Be True ${result} + +Projector Converts To Fragments + [Documentation] project_to_fragments produces ContextFragments + ${count}= Project To Fragments Count A 1 4 + Should Be Equal As Integers ${count} 2 + +Code Detail Map Resolves Levels + [Documentation] Built-in code detail map + ${depth}= Resolve Code Level FULL_SOURCE + Should Be Equal As Integers ${depth} 9 + +Inheritance Computes Child Context + [Documentation] PlanContextInheritance produces ChildContextResult + ${result}= Compute Child Context Default class://Child 1000 + Should Be True ${result} + +Inheritance With Skeleton Injection + [Documentation] Skeleton compressor fragments are injected + ${count}= Compute Child With Skeleton class://Child 1000 + Should Be Equal As Integers ${count} 2 + +Extract Child Focus From Decisions + [Documentation] extract_child_focus returns decisions + ${result}= Extract Focus class://A class://B + Should Be True ${result} diff --git a/robot/helper_depth_breadth_projection.py b/robot/helper_depth_breadth_projection.py new file mode 100644 index 000000000..66e30bb9c --- /dev/null +++ b/robot/helper_depth_breadth_projection.py @@ -0,0 +1,116 @@ +"""Robot Framework helper library for depth/breadth projection tests.""" + +from __future__ import annotations + +from cleveragents.application.services.depth_breadth_projection import ( + ChildContextResult, + DepthBreadthProjector, + PlanContextInheritance, + ProjectedNode, + ProjectionSpec, + code_detail_map, +) +from cleveragents.domain.models.acms.crp import ( + ContextFragment, + FragmentProvenance, +) + +_PROV = FragmentProvenance(resource_uri="test://robot") +_GRAPH = {"A": ["B"], "B": ["C"]} + + +def _make_frag( + uko_node: str = "test://node", + content: str = "test", + token_count: int = 100, + detail_depth: int = 3, +) -> ContextFragment: + return ContextFragment( + uko_node=uko_node, + content=content, + token_count=token_count, + relevance_score=0.8, + detail_depth=detail_depth, + provenance=_PROV, + ) + + +class _FakeContext: + def __init__(self, frags: list[ContextFragment], ctx_hash: str = "h123") -> None: + self.fragments = frags + self.context_hash = ctx_hash + + +class _MockCompressor: + def __init__(self, n: int = 2) -> None: + self._n = n + + def compress( + self, + fragments: tuple[ContextFragment, ...], + skeleton_budget: int, + ) -> tuple[ContextFragment, ...]: + return tuple(fragments[: self._n]) + + +def create_projection_spec(focus: str, breadth: int, depth: int) -> bool: + spec = ProjectionSpec(focus=(focus,), breadth=breadth, depth=depth) + return spec.focus == (focus,) and spec.breadth == breadth + + +def create_projected_node(uri: str, distance: int, depth: int) -> bool: + node = ProjectedNode(uri=uri, distance=distance, resolved_depth=depth) + return node.uri == uri and node.distance == distance + + +def project_and_count_nodes(focus: str, breadth: int, depth: int) -> int: + projector = DepthBreadthProjector() + spec = ProjectionSpec(focus=(focus,), breadth=breadth, depth=depth) + return len(projector.project(spec, _GRAPH)) + + +def project_with_gradient(focus: str, breadth: int, depth: int) -> bool: + projector = DepthBreadthProjector() + spec = ProjectionSpec( + focus=(focus,), breadth=breadth, depth=depth, depth_gradient=True + ) + nodes = projector.project(spec, _GRAPH) + by_uri = {n.uri: n for n in nodes} + # Focus at full depth, farthest at 0 + return by_uri[focus].resolved_depth == depth and by_uri["C"].resolved_depth == 0 + + +def project_to_fragments_count(focus: str, breadth: int, depth: int) -> int: + projector = DepthBreadthProjector() + spec = ProjectionSpec(focus=(focus,), breadth=breadth, depth=depth) + return len(projector.project_to_fragments(spec, _GRAPH)) + + +def resolve_code_level(level: str) -> int: + return code_detail_map().resolve(level) + + +def compute_child_context_default(focus: str, budget: int) -> bool: + frags = [_make_frag(uko_node=f"mod://m{i}") for i in range(3)] + ctx = _FakeContext(frags) + svc = PlanContextInheritance() + result = svc.compute_child_context( + parent_context=ctx, child_focus=[focus], child_token_budget=budget + ) + return isinstance(result, ChildContextResult) and focus in result.request.focus + + +def compute_child_with_skeleton(focus: str, budget: int) -> int: + frags = [_make_frag(uko_node=f"mod://m{i}") for i in range(3)] + ctx = _FakeContext(frags) + comp = _MockCompressor(n=2) + svc = PlanContextInheritance(skeleton_compressor=comp) + result = svc.compute_child_context( + parent_context=ctx, child_focus=[focus], child_token_budget=budget + ) + return len(result.skeleton_fragments) + + +def extract_focus(d1: str, d2: str) -> bool: + result = PlanContextInheritance.extract_child_focus([d1, d2]) + return d1 in result and d2 in result diff --git a/src/cleveragents/application/services/depth_breadth_projection.py b/src/cleveragents/application/services/depth_breadth_projection.py new file mode 100644 index 000000000..d174d5e88 --- /dev/null +++ b/src/cleveragents/application/services/depth_breadth_projection.py @@ -0,0 +1,626 @@ +"""Depth/Breadth Projection System and Skeleton Context Propagation. + +Implements the projection system described in ``docs/specification.md`` +§ Core Concepts > Depth/Breadth Projection (lines 25265-25340) and the +context inheritance mechanism from § Architecture > ACMS > Context +Inheritance Mechanism (lines 43067-43128). + +Components +---------- +- ``ProjectionSpec`` — Frozen Pydantic model capturing a projection request. +- ``ProjectedNode`` — Frozen Pydantic model: a UKO node materialized at a + resolved detail depth and distance from focus. +- ``DepthBreadthProjector`` — Stateless projector: BFS over a UKO graph + adjacency list, applying depth gradient to produce fragments at varying + detail levels. +- ``PlanContextInheritance`` — Service that computes a child plan's + inherited context request from the parent plan's assembled context, + using the SkeletonCompressor for budget-controlled skeleton injection. +""" + +from __future__ import annotations + +import math +from collections import deque +from collections.abc import Mapping, Sequence +from typing import Any + +import structlog +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from cleveragents.domain.models.acms.crp import ( + ContextFragment, + ContextRequest, + DetailLevelMap, + FragmentProvenance, +) +from cleveragents.domain.models.core.uko import UKONode + +logger = structlog.get_logger() + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_SKELETON_RATIO: float = 0.2 +"""Default fraction of child's token budget reserved for parent skeleton.""" + +EDGE_RELATIONS: tuple[str, ...] = ( + "uko:references", + "uko:dependsOn", + "uko:contains", +) +"""UKO relation types traversed during breadth expansion.""" + + +# --------------------------------------------------------------------------- +# ProjectionSpec — request description +# --------------------------------------------------------------------------- + + +class ProjectionSpec(BaseModel, frozen=True): + """Captures a depth/breadth projection request. + + A ``ProjectionSpec`` is the input to :class:`DepthBreadthProjector`: + it says *what* to focus on, how far to reach, and how much detail + to include. + + Spec reference: ``docs/specification.md`` lines 25265-25270. + + Attributes: + focus: URIs of entities at distance 0 (the focal items). + breadth: Maximum hop count outward from focus (0 = focus only). + depth: Target detail level (integer or named level string). + depth_gradient: When ``True``, detail decreases with distance. + domain: UKO domain namespace (e.g., ``"uko-code:"``). + """ + + focus: tuple[str, ...] = Field( + ..., + min_length=1, + description="URIs of focal entities (at least one required)", + ) + breadth: int = Field( + default=2, + ge=0, + description="Maximum relationship hops from focus", + ) + depth: int | str = Field( + default=3, + description=( + "Target detail level — raw integer or named level " + "resolved via the active DetailLevelMap" + ), + ) + depth_gradient: bool = Field( + default=True, + description="When True, detail decreases with distance from focus", + ) + domain: str = Field( + default="uko-code:", + min_length=1, + description="UKO domain namespace for DetailLevelMap resolution", + ) + + @field_validator("depth") + @classmethod + def _validate_depth( + cls: type[ProjectionSpec], + v: int | str, + ) -> int | str: + if isinstance(v, int) and v < 0: + raise ValueError("depth must be non-negative when integer") + return v + + model_config = ConfigDict(str_strip_whitespace=True) + + +# --------------------------------------------------------------------------- +# ProjectedNode — materialized result +# --------------------------------------------------------------------------- + + +class ProjectedNode(BaseModel, frozen=True): + """A UKO node materialized at a specific detail depth and distance. + + Produced by :class:`DepthBreadthProjector`. Each projected node + carries the resolved integer depth (after gradient) and the hop + distance from the closest focus item. + + Attributes: + uri: The UKO node URI. + distance: Hop count from the nearest focus item. + resolved_depth: Integer depth after gradient/clamping. + label: Human-readable label (from :class:`UKONode`). + """ + + uri: str = Field(..., min_length=1) + distance: int = Field(..., ge=0) + resolved_depth: int = Field(..., ge=0) + label: str = "" + + model_config = ConfigDict(str_strip_whitespace=True) + + +# --------------------------------------------------------------------------- +# DepthBreadthProjector — stateless graph projector +# --------------------------------------------------------------------------- + + +class DepthBreadthProjector: + """BFS projector over a UKO graph with depth gradient. + + Given an adjacency list of UKO edges and a :class:`ProjectionSpec`, + the projector performs a breadth-first traversal from the focus + items, collecting reachable nodes at each distance up to + ``spec.breadth``. When ``spec.depth_gradient`` is enabled the + resolved detail level *decreases* linearly with distance: + + .. math:: + + resolved = base - \\lfloor base \\cdot (distance / breadth) \\rfloor + + where ``base`` is the resolved base depth at distance 0. + + The projector is **stateless** — it carries no mutable state and + can be reused across calls. + + Spec reference: ``docs/specification.md`` lines 25265-25340. + """ + + def __init__( + self, *, detail_maps: Mapping[str, DetailLevelMap] | None = None + ) -> None: + self._detail_maps: dict[str, DetailLevelMap] = dict(detail_maps or {}) + self._logger = logger.bind(component="DepthBreadthProjector") + + @property + def detail_maps(self) -> Mapping[str, DetailLevelMap]: + """Return registered detail level maps.""" + return self._detail_maps + + def register_detail_map(self, domain: str, detail_map: DetailLevelMap) -> None: + """Register a :class:`DetailLevelMap` for a UKO domain.""" + self._detail_maps[domain] = detail_map + + def project( + self, + spec: ProjectionSpec, + adjacency: Mapping[str, Sequence[str]], + node_index: Mapping[str, UKONode] | None = None, + ) -> list[ProjectedNode]: + """Perform depth/breadth projection. + + Args: + spec: The projection request. + adjacency: Maps each UKO URI to its neighbors (bidirectional + edges from ``uko:references``, ``uko:dependsOn``, + ``uko:contains``). + node_index: Optional URI→UKONode lookup for labels. + + Returns: + List of :class:`ProjectedNode` sorted by distance then URI. + """ + base_depth = self._resolve_base_depth(spec) + + # BFS from focus items + visited: dict[str, int] = {} # uri → min distance + queue: deque[tuple[str, int]] = deque() + + for uri in spec.focus: + if uri not in visited: + visited[uri] = 0 + queue.append((uri, 0)) + + while queue: + current, dist = queue.popleft() + if dist >= spec.breadth: + continue + for neighbor in adjacency.get(current, ()): + if neighbor not in visited: + visited[neighbor] = dist + 1 + queue.append((neighbor, dist + 1)) + + # Build projected nodes + nodes: list[ProjectedNode] = [] + for uri, distance in visited.items(): + resolved = self._apply_gradient( + base_depth=base_depth, + distance=distance, + max_distance=spec.breadth, + gradient_enabled=spec.depth_gradient, + ) + label = "" + if node_index and uri in node_index: + label = node_index[uri].label + nodes.append( + ProjectedNode( + uri=uri, + distance=distance, + resolved_depth=resolved, + label=label, + ) + ) + + nodes.sort(key=lambda n: (n.distance, n.uri)) + + self._logger.debug( + "Projection complete", + focus_count=len(spec.focus), + breadth=spec.breadth, + base_depth=base_depth, + projected_nodes=len(nodes), + ) + return nodes + + def project_to_fragments( + self, + spec: ProjectionSpec, + adjacency: Mapping[str, Sequence[str]], + node_index: Mapping[str, UKONode] | None = None, + *, + token_estimator: _TokenEstimator | None = None, + ) -> list[ContextFragment]: + """Project and convert to :class:`ContextFragment` instances. + + Convenience wrapper over :meth:`project` that produces fragments + suitable for direct injection into the ACMS pipeline. + + Args: + spec: Projection request. + adjacency: Graph adjacency list. + node_index: Optional URI→UKONode lookup. + token_estimator: Optional callable ``(uri, depth) -> int`` + that estimates token cost. Defaults to depth + 1. + + Returns: + List of :class:`ContextFragment` sorted by distance then URI. + """ + projected = self.project(spec, adjacency, node_index) + estimator = token_estimator or _default_token_estimator + fragments: list[ContextFragment] = [] + for node in projected: + tokens = estimator(node.uri, node.resolved_depth) + relevance = self._distance_relevance(node.distance, spec.breadth) + fragments.append( + ContextFragment( + uko_node=node.uri, + content=f"{node.label or node.uri} [depth={node.resolved_depth}]", + detail_depth=node.resolved_depth, + token_count=tokens, + relevance_score=relevance, + provenance=FragmentProvenance(resource_uri=node.uri), + metadata={ + "distance": node.distance, + "projected": True, + "base_depth": self._resolve_base_depth(spec), + }, + ) + ) + return fragments + + # -- internal helpers -------------------------------------------------- + + def _resolve_base_depth(self, spec: ProjectionSpec) -> int: + """Resolve the base depth from the spec, using DetailLevelMap if available.""" + detail_map = self._detail_maps.get(spec.domain) + if detail_map is not None: + return detail_map.resolve(spec.depth) + if isinstance(spec.depth, int): + return spec.depth + msg = ( + f"Cannot resolve named depth {spec.depth!r}: " + f"no DetailLevelMap registered for domain {spec.domain!r}" + ) + raise ValueError(msg) + + @staticmethod + def _apply_gradient( + *, + base_depth: int, + distance: int, + max_distance: int, + gradient_enabled: bool, + ) -> int: + """Compute the resolved depth at a given distance. + + When gradient is disabled, every node gets ``base_depth``. + When enabled, depth decreases linearly from ``base_depth`` + at distance 0 to 0 at ``max_distance``. This matches the + spec examples (lines 25276-25304). + """ + if not gradient_enabled or max_distance == 0 or distance == 0: + return base_depth + reduction = math.floor(base_depth * (distance / max_distance)) + return max(0, base_depth - reduction) + + @staticmethod + def _distance_relevance(distance: int, max_distance: int) -> float: + """Compute a 0.0-1.0 relevance score inversely proportional to distance.""" + if max_distance == 0: + return 1.0 + return round(max(0.0, 1.0 - distance / (max_distance + 1)), 4) + + +# --------------------------------------------------------------------------- +# Token estimation +# --------------------------------------------------------------------------- + +_TokenEstimator = Any # callable[[str, int], int] + + +def _default_token_estimator(_uri: str, depth: int) -> int: + """Placeholder estimator: tokens = depth + 1.""" + return depth + 1 + + +# --------------------------------------------------------------------------- +# PlanContextInheritance — skeleton context propagation +# --------------------------------------------------------------------------- + + +class InheritanceConfig(BaseModel, frozen=True): + """Configuration for :class:`PlanContextInheritance`. + + Attributes: + skeleton_ratio: Fraction of child's token budget reserved + for the parent skeleton (default 0.2, per spec §43082). + min_skeleton_tokens: Minimum tokens for a skeleton to be + useful. Below this, no skeleton is injected. + max_detail_increase: Maximum detail level increase per + depth-in-tree delta. + """ + + skeleton_ratio: float = Field( + default=DEFAULT_SKELETON_RATIO, + ge=0.0, + le=1.0, + ) + min_skeleton_tokens: int = Field(default=32, ge=0) + max_detail_increase: int = Field(default=3, ge=0) + + model_config = ConfigDict(str_strip_whitespace=True) + + +class ChildContextResult(BaseModel, frozen=True): + """Result of a child context inheritance computation. + + Contains the :class:`ContextRequest` for the child plan plus + skeleton fragments and metadata from the parent context. + + Attributes: + request: The child plan's context request. + skeleton_fragments: Compressed parent fragments for injection. + parent_context_hash: Hash of the parent's assembled context. + skeleton_budget: Tokens reserved for the skeleton. + depth_delta: Depth-in-tree difference between child and parent. + """ + + request: ContextRequest + skeleton_fragments: tuple[ContextFragment, ...] = () + parent_context_hash: str = "" + skeleton_budget: int = 0 + depth_delta: int = 0 + + model_config = ConfigDict(str_strip_whitespace=True) + + +class PlanContextInheritance: + """Compute child plan context from parent assembled context. + + Implements the ``PlanContextInheritance.compute_child_context()`` + pattern from ``docs/specification.md`` lines 43069-43101. + + The service: + 1. Extracts the child's focus from the parent plan's decisions. + 2. Computes depth/breadth adjustments based on tree depth delta. + 3. Builds a skeleton from parent context using the pipeline's + :class:`SkeletonCompressor`. + 4. Returns a :class:`ChildContextResult` containing the request + and skeleton data. + """ + + def __init__( + self, + *, + config: InheritanceConfig | None = None, + skeleton_compressor: _SkeletonCompressorLike | None = None, + ) -> None: + self._config = config or InheritanceConfig() + self._compressor = skeleton_compressor + self._logger = logger.bind(component="PlanContextInheritance") + + @property + def config(self) -> InheritanceConfig: + """Return the inheritance configuration.""" + return self._config + + def compute_child_context( + self, + *, + parent_context: _AssembledContextLike, + child_focus: Sequence[str], + child_token_budget: int, + parent_depth_in_tree: int = 0, + child_depth_in_tree: int = 1, + ) -> ChildContextResult: + """Build the child plan's initial context request. + + Args: + parent_context: The parent's :class:`AssembledContext`. + child_focus: URIs the child plan focuses on. + child_token_budget: Total token budget for the child. + parent_depth_in_tree: Parent's depth in the plan tree. + child_depth_in_tree: Child's depth in the plan tree. + + Returns: + A :class:`ChildContextResult` containing the request and + skeleton fragments. + """ + depth_delta = child_depth_in_tree - parent_depth_in_tree + if depth_delta < 0: + msg = "child_depth_in_tree must be >= parent_depth_in_tree" + raise ValueError(msg) + + # 1. Compute child detail = parent average + delta (clamped) + avg_detail = self._avg_detail(parent_context) + child_detail = min( + avg_detail + depth_delta * self._config.max_detail_increase, + 9, # Absolute max detail (FULL_SOURCE) + ) + + # 2. Compute child breadth = narrower than parent + parent_breadth = getattr(parent_context, "avg_breadth", 2) + child_breadth = max(1, parent_breadth - depth_delta) + + # 3. Skeleton budget + skeleton_budget = int(child_token_budget * self._config.skeleton_ratio) + skeleton_fragments: tuple[ContextFragment, ...] = () + + if skeleton_budget >= self._config.min_skeleton_tokens and self._compressor: + parent_fragments = tuple(parent_context.fragments) + skeleton_fragments = self._compressor.compress( + parent_fragments, + skeleton_budget, + ) + self._logger.info( + "Skeleton injected", + skeleton_fragments=len(skeleton_fragments), + skeleton_budget=skeleton_budget, + parent_fragments=len(parent_fragments), + ) + + # 4. Build inherited context request + context_hash = getattr(parent_context, "context_hash", "") + + request = ContextRequest( + focus=list(child_focus), + breadth=child_breadth, + depth=child_detail, + depth_gradient=True, + purpose=f"Inherited context from parent (hash={context_hash})", + ) + + return ChildContextResult( + request=request, + skeleton_fragments=skeleton_fragments, + parent_context_hash=context_hash, + skeleton_budget=skeleton_budget, + depth_delta=depth_delta, + ) + + @staticmethod + def _avg_detail(context: _AssembledContextLike) -> int: + """Compute average detail depth of assembled fragments.""" + fragments = context.fragments + if not fragments: + return 0 + total = sum(getattr(f, "detail_depth", 0) for f in fragments) + return total // len(fragments) + + @staticmethod + def extract_child_focus( + parent_decisions: Sequence[str], + fallback_focus: Sequence[str] | None = None, + ) -> list[str]: + """Extract child focus from parent plan decisions. + + In the absence of a full decision model, this method accepts + a list of URIs from the parent's decisions and returns them + as the child's focus. A fallback list is used if decisions + are empty. + """ + if parent_decisions: + return list(parent_decisions) + return list(fallback_focus or []) + + +# --------------------------------------------------------------------------- +# Structural protocols (duck-typed, avoid circular imports) +# --------------------------------------------------------------------------- + +_SkeletonCompressorLike = Any +"""Any object with ``compress(fragments, skeleton_budget) -> tuple[...]``.""" + +_AssembledContextLike = Any +"""Any object with ``fragments``, ``context_hash``, optionally ``avg_breadth``.""" + + +# --------------------------------------------------------------------------- +# Built-in DetailLevelMap presets +# --------------------------------------------------------------------------- + + +def code_detail_map() -> DetailLevelMap: + """Return the built-in source-code detail level map. + + Maps named levels to integer depths following the spec examples + (lines 25280-25304): + + - ``MODULE_LISTING`` = 0 + - ``CLASS_NAMES`` = 2 + - ``SIGNATURES`` = 4 + - ``MEMBER_SUMMARY`` = 6 + - ``FULL_SOURCE`` = 9 + """ + return DetailLevelMap( + domain="uko-code:", + levels={ + "MODULE_LISTING": 0, + "CLASS_NAMES": 2, + "SIGNATURES": 4, + "MEMBER_SUMMARY": 6, + "FULL_SOURCE": 9, + }, + max_depth=9, + ) + + +def docs_detail_map() -> DetailLevelMap: + """Return the built-in documentation detail level map. + + Maps named levels to integer depths following the spec examples + (lines 25310-25336): + + - ``TITLE_ONLY`` = 0 + - ``TABLE_OF_CONTENTS`` = 2 + - ``SECTION_HEADINGS`` = 4 + - ``TOPIC_SENTENCES`` = 6 + - ``SECTION_SUMMARIES`` = 8 + - ``FULL_CONTENT`` = 10 + """ + return DetailLevelMap( + domain="uko-doc:", + levels={ + "TITLE_ONLY": 0, + "TABLE_OF_CONTENTS": 2, + "SECTION_HEADINGS": 4, + "TOPIC_SENTENCES": 6, + "SECTION_SUMMARIES": 8, + "FULL_CONTENT": 10, + }, + max_depth=10, + ) + + +def database_detail_map() -> DetailLevelMap: + """Return the built-in database detail level map. + + - ``TABLE_LISTING`` = 0 + - ``COLUMN_NAMES`` = 3 + - ``SCHEMA_DETAILS`` = 6 + - ``INDEX_DETAILS`` = 9 + - ``FULL_CATALOG`` = 11 + """ + return DetailLevelMap( + domain="uko-data:", + levels={ + "TABLE_LISTING": 0, + "COLUMN_NAMES": 3, + "SCHEMA_DETAILS": 6, + "INDEX_DETAILS": 9, + "FULL_CATALOG": 11, + }, + max_depth=11, + ) -- 2.52.0