feat(acms): implement core ACMS pipeline components (StrategySelector, BudgetAllocator, FragmentScorer, BudgetPacker, FragmentOrderer) #10769

Merged
HAL9000 merged 2 commits from feat/acms-core-pipeline-components into master 2026-06-06 14:45:36 +00:00
3 changed files with 1786 additions and 0 deletions
@@ -0,0 +1,364 @@
@acms @acms_core_pipeline
Feature: ACMS Core Pipeline Components
As a CleverAgents developer
I want core ACMS pipeline components implemented
So that the pipeline can select strategies, allocate budgets, score fragments,
pack within constraints, and order for optimal LLM consumption
# ===========================================================================
# ActorPhaseStrategySelector
# ===========================================================================
@strategy_selector
Scenario: Select strategies with no actor type or plan phase
Given a set of core pipeline strategies
When I select strategies with no actor_type or plan_phase
Then all core strategies with positive confidence should be returned
And the core strategies should be sorted by confidence descending
@strategy_selector
Scenario: Select strategies boosts preferred strategies for planner actor
Given a set of core pipeline strategies
When I select strategies with actor_type "planner" only
Then the "relevance" strategy should have boosted confidence
@strategy_selector
Scenario: Select strategies boosts preferred strategies for executor actor
Given a set of core pipeline strategies
When I select strategies with actor_type "executor" only
Then the "tiered" strategy should have boosted confidence
@strategy_selector
Scenario: Select strategies boosts preferred strategies for strategize phase
Given a set of core pipeline strategies
When I select strategies with plan_phase "strategize" only
Then the "relevance" strategy should have boosted confidence
@strategy_selector
Scenario: Select strategies applies both actor and phase boosts
Given a set of core pipeline strategies
When I select strategies with actor_type "planner" and plan_phase "strategize"
Then the "relevance" strategy should have maximum boosted confidence
@strategy_selector
Scenario: Select strategies excludes zero-confidence strategies
Given a set of core pipeline strategies including a zero-confidence strategy
When I select strategies with no actor_type or plan_phase
Then the core zero-confidence strategy should not be in the results
@strategy_selector
Scenario: Select strategies with empty strategy list returns empty
Given an empty strategy list
When I select strategies with no actor_type or plan_phase
Then 0 strategies should be selected
@strategy_selector
Scenario: Confidence is clamped to 1.0 after boost
Given a strategy with confidence 0.9
When I select strategies with actor_type "planner" and plan_phase "strategize"
Then the strategy confidence should not exceed 1.0
# ===========================================================================
# SpecBudgetAllocator
# ===========================================================================
@budget_allocator
Scenario: Allocate budget to empty candidates returns empty
Given an empty candidate list
When I allocate budget 1000 with SpecBudgetAllocator
Then 0 allocations should be returned
@budget_allocator
Scenario: Allocate budget to single candidate gives full budget
Given a single candidate with confidence 0.8
When I allocate budget 1000 with SpecBudgetAllocator
Then 1 allocation should be returned
And the single allocation should receive 1000 tokens
@budget_allocator
Scenario: Allocate budget proportionally to two candidates
Given two candidates with equal confidence 0.5
When I allocate budget 1000 with SpecBudgetAllocator
Then 2 allocations should be returned
And the total allocated tokens should equal 1000
@budget_allocator
Scenario: Allocate budget uses spec formula with quality scores
Given two candidates with different quality scores
When I allocate budget 1000 with SpecBudgetAllocator
Then the higher quality candidate should receive more tokens
@budget_allocator
Scenario: Allocate budget with zero total weight falls back to equal split
Given two candidates with zero confidence
When I allocate budget 1000 with SpecBudgetAllocator
Then 2 allocations should be returned
And the total allocated tokens should equal 1000
@budget_allocator
Scenario: Allocate budget with min_useful_budget excludes small candidates
Given three candidates where one would receive very few tokens
When I allocate budget 100 with SpecBudgetAllocator and min_useful_budget 30
Then the small candidate should be excluded from allocations
@budget_allocator
Scenario: Allocate budget total never exceeds budget
Given three candidates with varying confidence
When I allocate budget 500 with SpecBudgetAllocator
Then the total allocated tokens should equal 500
# ===========================================================================
# RelevanceRecencyPriorityScorer
# ===========================================================================
@scorer
Scenario: Score empty fragment list returns empty
Given an empty core pipeline fragment list
When I score the fragments with RelevanceRecencyPriorityScorer
Then 0 core scored fragments should be returned
@scorer
Scenario: Score single fragment preserves metadata
Given a core pipeline fragment with relevance 0.8
When I score the fragments with RelevanceRecencyPriorityScorer
Then the scored fragment should have _original_relevance metadata "0.8"
@scorer
Scenario: Score produces composite from relevance recency and priority
Given a core pipeline fragment with relevance 0.8 and priority 0.9
When I score the fragments with RelevanceRecencyPriorityScorer
Then the scored fragment composite score should be between 0.0 and 1.0
@scorer
Scenario: Score is deterministic for identical inputs
Given two identical core pipeline fragments
When I score both fragment sets with RelevanceRecencyPriorityScorer
Then both scored fragments should have identical composite scores
@scorer
Scenario: Score recency normalises across fragment timestamps
Given two core pipeline fragments with different timestamps
When I score the fragments with RelevanceRecencyPriorityScorer
Then the newer fragment should have higher recency score
@scorer
Scenario: Score with same timestamps gives recency 1.0
Given two core pipeline fragments with identical timestamps
When I score the fragments with RelevanceRecencyPriorityScorer
Then all scored fragments should have recency score 1.0
@scorer
Scenario: Score composite is clamped to 1.0
Given a core pipeline fragment with maximum relevance and priority
When I score the fragments with RelevanceRecencyPriorityScorer
Then the scored fragment composite score should be at most 1.0
@scorer
Scenario: Score composite is clamped to 0.0
Given a core pipeline fragment with zero relevance and zero priority
When I score the fragments with RelevanceRecencyPriorityScorer
Then the scored fragment composite score should be at least 0.0
@scorer
Scenario: Score with custom weights uses configured weights
Given a core pipeline fragment with relevance 1.0 and priority 0.0
And scorer configured with relevance_weight 1.0 recency_weight 0.0 priority_weight 0.0
When I score the fragments with custom RelevanceRecencyPriorityScorer
Then the scored fragment composite score should be 1.0
@scorer
Scenario: Score extracts priority from metadata
Given a core pipeline fragment with metadata priority "0.9"
When I score the fragments with RelevanceRecencyPriorityScorer
Then the scored fragment should have _score_priority metadata near "0.9"
@scorer
Scenario: Score defaults priority to 0.5 when not in metadata
Given a core pipeline fragment with no priority metadata
When I score the fragments with RelevanceRecencyPriorityScorer
Then the scored fragment should have _score_priority metadata "0.5"
# ===========================================================================
# ConstrainedKnapsackPacker
# ===========================================================================
@packer
Scenario: Pack empty fragment list returns empty
Given an empty core pipeline fragment list
And a core pipeline budget with max_tokens 1000 and reserved_tokens 0
When I pack the fragments with ConstrainedKnapsackPacker
Then 0 core packed fragments should be returned
@packer
Scenario: Pack fragments within token budget
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | alpha | 0.9 | 100 | 3 |
| project://app/io.py | beta | 0.7 | 100 | 3 |
| project://app/util.py | gamma | 0.5 | 100 | 3 |
And a core pipeline budget with max_tokens 250 and reserved_tokens 0
When I pack the fragments with ConstrainedKnapsackPacker
Then 2 core packed fragments should be returned
And the core packed total tokens should be at most 250
@packer
Scenario: Pack respects max_file_size constraint
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | short content | 0.9 | 10 | 3 |
| project://app/io.py | this is a much longer text | 0.7 | 20 | 3 |
And a core pipeline budget with max_tokens 1000 and reserved_tokens 0
And a ConstrainedKnapsackPacker with max_file_size 15
When I pack the fragments with ConstrainedKnapsackPacker
Then 1 core packed fragment should be returned
And the core packed fragment uko_node should be "project://app/main.py"
@packer
Scenario: Pack respects max_total_size constraint
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | hello | 0.9 | 10 | 3 |
| project://app/io.py | world | 0.7 | 10 | 3 |
| project://app/util.py | extra | 0.5 | 10 | 3 |
And a core pipeline budget with max_tokens 1000 and reserved_tokens 0
And a ConstrainedKnapsackPacker with max_total_size 4
When I pack the fragments with ConstrainedKnapsackPacker
Then 0 core packed fragments should be returned
@packer
Scenario: Pack prefers higher scored fragments
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | alpha | 0.3 | 100 | 3 |
| project://app/io.py | beta | 0.9 | 100 | 3 |
And a core pipeline budget with max_tokens 150 and reserved_tokens 0
When I pack the fragments with ConstrainedKnapsackPacker
Then 1 core packed fragment should be returned
And the core packed fragment uko_node should be "project://app/io.py"
@packer
Scenario: Pack with context_view uses view constraints
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | hello | 0.9 | 10 | 3 |
| project://app/io.py | world | 0.7 | 10 | 3 |
And a core pipeline budget with max_tokens 1000 and reserved_tokens 0
And a ConstrainedKnapsackPacker with context_view max_total_size 4
When I pack the fragments with ConstrainedKnapsackPacker
Then 0 core packed fragments should be returned
@packer
Scenario: Pack with budget=0 returns empty
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | hello | 0.9 | 10 | 3 |
And a core pipeline budget with max_tokens 1 and reserved_tokens 0
When I pack the fragments with ConstrainedKnapsackPacker
Then 0 core packed fragments should be returned
# ===========================================================================
# PriorityCoherenceOrderer
# ===========================================================================
@orderer
Scenario: Order empty fragment list returns empty
Given an empty core pipeline fragment list
When I order the fragments with PriorityCoherenceOrderer
Then 0 core ordered fragments should be returned
@orderer
Scenario: Order single fragment returns it unchanged
Given a core pipeline fragment with relevance 0.8
When I order the fragments with PriorityCoherenceOrderer
Then 1 core ordered fragment should be returned
@orderer
Scenario: Order preserves all fragments
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/src/main.py | alpha | 0.9 | 10 | 3 |
| project://app/src/io.py | beta | 0.7 | 10 | 3 |
| project://lib/util/util.py | gamma | 0.5 | 10 | 3 |
When I order the fragments with PriorityCoherenceOrderer
Then 3 core ordered fragments should be returned
@orderer
Scenario: Order places high-priority fragments first
Given the following core pipeline fragments with priorities:
| uko_node | content | score | tokens | depth | priority |
| project://app/src/main.py | alpha | 0.5 | 10 | 3 | 0.9 |
| project://app/src/io.py | beta | 0.9 | 10 | 3 | 0.1 |
When I order the fragments with PriorityCoherenceOrderer
Then the first core ordered fragment should have uko_node "project://app/src/main.py"
@orderer
Scenario: Order groups related fragments together
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/src/main.py | alpha | 0.9 | 10 | 3 |
| project://lib/util/util.py | beta | 0.8 | 10 | 3 |
| project://app/src/io.py | gamma | 0.7 | 10 | 3 |
When I order the fragments with PriorityCoherenceOrderer
Then fragments from "project://app" should be adjacent
@orderer
Scenario: Order with default priority 0.5 falls back to relevance
Given the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/src/main.py | alpha | 0.3 | 10 | 3 |
| project://lib/util/util.py | beta | 0.9 | 10 | 3 |
When I order the fragments with PriorityCoherenceOrderer
Then the first core ordered fragment should have uko_node "project://lib/util/util.py"
# ===========================================================================
# Pipeline Integration
# ===========================================================================
@pipeline_integration
Scenario: Inject ActorPhaseStrategySelector into pipeline
Given the ACMS pipeline with an ActorPhaseStrategySelector
And the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | hello | 0.8 | 10 | 3 |
When I assemble context through the core pipeline
Then the core pipeline output should contain 1 fragment
@pipeline_integration
Scenario: Inject SpecBudgetAllocator into pipeline
Given the ACMS pipeline with a SpecBudgetAllocator
And the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | hello | 0.8 | 10 | 3 |
When I assemble context through the core pipeline
Then the core pipeline output should contain 1 fragment
@pipeline_integration
Scenario: Inject RelevanceRecencyPriorityScorer into pipeline
Given the ACMS pipeline with a RelevanceRecencyPriorityScorer
And the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | hello | 0.8 | 10 | 3 |
When I assemble context through the core pipeline
Then the core pipeline output fragments should have updated relevance scores
@pipeline_integration
Scenario: Inject ConstrainedKnapsackPacker into pipeline
Given the ACMS pipeline with a ConstrainedKnapsackPacker
And the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | alpha | 0.9 | 100 | 3 |
| project://app/io.py | beta | 0.7 | 100 | 3 |
| project://app/util.py | gamma | 0.5 | 100 | 3 |
And a core pipeline budget with max_tokens 250 and reserved_tokens 0
When I assemble context through the core pipeline with budget
Then the core pipeline output total tokens should be at most 250
@pipeline_integration
Scenario: Inject PriorityCoherenceOrderer into pipeline
Given the ACMS pipeline with a PriorityCoherenceOrderer
And the following core pipeline fragments:
| uko_node | content | score | tokens | depth |
| project://app/main.py | hello | 0.8 | 10 | 3 |
| project://lib/util.py | world | 0.6 | 15 | 5 |
When I assemble context through the core pipeline
Then the core pipeline output should contain 2 fragments
@@ -0,0 +1,842 @@
"""Step definitions for features/acms_core_pipeline_components.feature.
Tests the ACMS core pipeline components directly in-memory.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.acms_core_pipeline import (
ActorPhaseStrategySelector,
ConstrainedKnapsackPacker,
PriorityCoherenceOrderer,
RelevanceRecencyPriorityScorer,
SpecBudgetAllocator,
)
from cleveragents.application.services.acms_service import (
ACMSPipeline,
StrategyCapabilities,
)
from cleveragents.domain.models.core.context_fragment import (
ContextBudget,
ContextFragment,
FragmentProvenance,
)
from cleveragents.domain.models.core.context_policy import ContextView
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_DEFAULT_PROVENANCE = FragmentProvenance(resource_uri="test://core-pipeline")
_TEST_PLAN_ID = "01JQTESTCP00000000000000CC"
def _make_core_fragment(**kwargs: Any) -> ContextFragment:
"""Create a ContextFragment with sensible core pipeline test defaults."""
kwargs.setdefault("uko_node", "test://default")
kwargs.setdefault("content", "test content")
kwargs.setdefault("token_count", 10)
kwargs.setdefault("provenance", _DEFAULT_PROVENANCE)
return ContextFragment(**kwargs)
class _MockStrategy:
"""A simple mock strategy for testing."""
def __init__(
self,
name: str,
confidence: float,
quality_score: float = 1.0,
) -> None:
self._name = name
self._confidence = confidence
self._quality_score = quality_score
@property
def name(self) -> str:
return self._name
@property
def capabilities(self) -> StrategyCapabilities:
return StrategyCapabilities(quality_score=self._quality_score)
def can_handle(self, request: dict[str, Any]) -> float:
return self._confidence
def assemble(self, fragments: Any, budget: Any) -> list[Any]:
return list(fragments)
def explain(self) -> str:
return f"Mock strategy: {self._name}"
# ---------------------------------------------------------------------------
# ActorPhaseStrategySelector — Given steps
# ---------------------------------------------------------------------------
@given("a set of core pipeline strategies")
def step_given_core_strategies(context: Context) -> None:
context.core_strategies = [
_MockStrategy("relevance", 0.8),
_MockStrategy("tiered", 0.7),
_MockStrategy("recency", 0.6),
_MockStrategy("arce", 0.95),
]
@given("a set of core pipeline strategies including a zero-confidence strategy")
def step_given_core_strategies_with_zero(context: Context) -> None:
context.core_strategies = [
_MockStrategy("relevance", 0.8),
_MockStrategy("zero-strategy", 0.0),
]
@given("an empty strategy list")
def step_given_empty_strategy_list(context: Context) -> None:
context.core_strategies = []
@given("a strategy with confidence {conf:g}")
def step_given_strategy_with_confidence(context: Context, conf: float) -> None:
context.core_strategies = [
_MockStrategy("relevance", conf),
]
# ---------------------------------------------------------------------------
# ActorPhaseStrategySelector — When steps
# ---------------------------------------------------------------------------
@when("I select strategies with no actor_type or plan_phase")
def step_select_no_context(context: Context) -> None:
selector = ActorPhaseStrategySelector()
context.selector_result = selector.select(context.core_strategies, {})
@when('I select strategies with actor_type "{actor_type}" only')
def step_select_with_actor_only(context: Context, actor_type: str) -> None:
selector = ActorPhaseStrategySelector()
context.selector_result = selector.select(
context.core_strategies, {"actor_type": actor_type}
)
@when('I select strategies with plan_phase "{plan_phase}" only')
def step_select_with_phase_only(context: Context, plan_phase: str) -> None:
selector = ActorPhaseStrategySelector()
context.selector_result = selector.select(
context.core_strategies, {"plan_phase": plan_phase}
)
@when(
'I select strategies with actor_type "{actor_type}" '
'and plan_phase "{plan_phase_val}"'
)
def step_select_with_actor_and_phase(
context: Context, actor_type: str, plan_phase_val: str
) -> None:
selector = ActorPhaseStrategySelector()
context.selector_result = selector.select(
context.core_strategies,
{"actor_type": actor_type, "plan_phase": plan_phase_val},
)
# ---------------------------------------------------------------------------
# ActorPhaseStrategySelector — Then steps
# ---------------------------------------------------------------------------
@then("all core strategies with positive confidence should be returned")
def step_all_positive_confidence(context: Context) -> None:
result_names = {s.name for s, _ in context.selector_result}
for strategy in context.core_strategies:
if strategy.can_handle({}) > 0.0:
assert strategy.name in result_names, f"Expected {strategy.name} in results"
@then("the core strategies should be sorted by confidence descending")
def step_sorted_by_confidence(context: Context) -> None:
confidences = [c for _, c in context.selector_result]
assert confidences == sorted(confidences, reverse=True), (
f"Not sorted: {confidences}"
)
@then('the "{name}" strategy should have boosted confidence')
def step_strategy_boosted(context: Context, name: str) -> None:
for strategy, confidence in context.selector_result:
if strategy.name == name:
base = strategy.can_handle({})
assert confidence > base, (
f"Expected {name} confidence > {base}, got {confidence}"
)
return
msg = f"Strategy {name} not found in results"
raise AssertionError(msg)
@then('the "{name}" strategy should have maximum boosted confidence')
def step_strategy_max_boosted(context: Context, name: str) -> None:
for strategy, confidence in context.selector_result:
if strategy.name == name:
base = strategy.can_handle({})
assert confidence > base, (
f"Expected {name} confidence > {base}, got {confidence}"
)
assert confidence <= 1.0, f"Confidence {confidence} exceeds 1.0"
return
msg = f"Strategy {name} not found in results"
raise AssertionError(msg)
@then("the core zero-confidence strategy should not be in the results")
def step_zero_confidence_excluded(context: Context) -> None:
result_names = {s.name for s, _ in context.selector_result}
assert "zero-strategy" not in result_names, (
"Zero-confidence strategy should be excluded"
)
@then("{count:d} strategies should be selected")
def step_strategies_count(context: Context, count: int) -> None:
assert len(context.selector_result) == count, (
f"Expected {count}, got {len(context.selector_result)}"
)
@then("the strategy confidence should not exceed 1.0")
def step_confidence_not_exceed_one(context: Context) -> None:
for _, confidence in context.selector_result:
assert confidence <= 1.0, f"Confidence {confidence} exceeds 1.0"
# ---------------------------------------------------------------------------
# SpecBudgetAllocator — Given steps
# ---------------------------------------------------------------------------
@given("an empty candidate list")
def step_given_empty_candidates(context: Context) -> None:
context.budget_candidates: list[tuple[Any, float]] = []
@given("a single candidate with confidence {conf:g}")
def step_given_single_candidate(context: Context, conf: float) -> None:
context.budget_candidates = [(_MockStrategy("relevance", conf), conf)]
@given("two candidates with equal confidence {conf:g}")
def step_given_two_equal_candidates(context: Context, conf: float) -> None:
context.budget_candidates = [
(_MockStrategy("relevance", conf), conf),
(_MockStrategy("tiered", conf), conf),
]
@given("two candidates with different quality scores")
def step_given_two_different_quality(context: Context) -> None:
context.budget_candidates = [
(_MockStrategy("high-quality", 0.8, quality_score=0.9), 0.8),
(_MockStrategy("low-quality", 0.8, quality_score=0.1), 0.8),
]
@given("two candidates with zero confidence")
def step_given_two_zero_confidence(context: Context) -> None:
context.budget_candidates = [
(_MockStrategy("a", 0.0), 0.0),
(_MockStrategy("b", 0.0), 0.0),
]
@given("three candidates where one would receive very few tokens")
def step_given_three_candidates_one_small(context: Context) -> None:
context.budget_candidates = [
(_MockStrategy("big-a", 0.9), 0.9),
(_MockStrategy("big-b", 0.8), 0.8),
(_MockStrategy("tiny", 0.01), 0.01),
]
@given("three candidates with varying confidence")
def step_given_three_varying_candidates(context: Context) -> None:
context.budget_candidates = [
(_MockStrategy("a", 0.9), 0.9),
(_MockStrategy("b", 0.6), 0.6),
(_MockStrategy("c", 0.3), 0.3),
]
# ---------------------------------------------------------------------------
# SpecBudgetAllocator — When steps
# ---------------------------------------------------------------------------
@when("I allocate budget {budget:d} with SpecBudgetAllocator")
def step_allocate_budget(context: Context, budget: int) -> None:
allocator = SpecBudgetAllocator()
context.allocation_result = allocator.allocate(context.budget_candidates, budget)
@when(
"I allocate budget {budget:d} with SpecBudgetAllocator "
"and min_useful_budget {min_budget:d}"
)
def step_allocate_budget_with_min(
context: Context, budget: int, min_budget: int
) -> None:
allocator = SpecBudgetAllocator(min_useful_budget=min_budget)
context.allocation_result = allocator.allocate(context.budget_candidates, budget)
# ---------------------------------------------------------------------------
# SpecBudgetAllocator — Then steps
# ---------------------------------------------------------------------------
@then("{count:d} allocation should be returned")
def step_allocations_count_singular(context: Context, count: int) -> None:
assert len(context.allocation_result) == count, (
f"Expected {count}, got {len(context.allocation_result)}"
)
@then("{count:d} allocations should be returned")
def step_allocations_count_plural(context: Context, count: int) -> None:
assert len(context.allocation_result) == count, (
f"Expected {count}, got {len(context.allocation_result)}"
)
@then("the single allocation should receive {tokens:d} tokens")
def step_single_allocation_tokens(context: Context, tokens: int) -> None:
assert len(context.allocation_result) == 1
_, _, allocated = context.allocation_result[0]
assert allocated == tokens, f"Expected {tokens}, got {allocated}"
@then("the total allocated tokens should equal {total:d}")
def step_total_allocated_tokens(context: Context, total: int) -> None:
actual = sum(t for _, _, t in context.allocation_result)
assert actual == total, f"Expected {total}, got {actual}"
@then("the higher quality candidate should receive more tokens")
def step_higher_quality_more_tokens(context: Context) -> None:
assert len(context.allocation_result) == 2
_, _, high_tokens = context.allocation_result[0]
_, _, low_tokens = context.allocation_result[1]
assert high_tokens > low_tokens, (
f"Expected high-quality ({high_tokens}) > low-quality ({low_tokens})"
)
@then("the small candidate should be excluded from allocations")
def step_small_candidate_excluded(context: Context) -> None:
names = {s.name for s, _, _ in context.allocation_result}
assert "tiny" not in names, f"Expected 'tiny' to be excluded, got {names}"
# ---------------------------------------------------------------------------
# RelevanceRecencyPriorityScorer — Given steps
# ---------------------------------------------------------------------------
@given("an empty core pipeline fragment list")
def step_given_empty_core_fragments(context: Context) -> None:
context.core_fragments: list[ContextFragment] = []
@given("a core pipeline fragment with relevance {rel:g}")
def step_given_core_fragment_relevance(context: Context, rel: float) -> None:
context.core_fragments = [_make_core_fragment(relevance_score=rel)]
@given("a core pipeline fragment with relevance {rel:g} and priority {pri:g}")
def step_given_core_fragment_relevance_priority(
context: Context, rel: float, pri: float
) -> None:
context.core_fragments = [
_make_core_fragment(
relevance_score=rel,
metadata={"priority": str(pri)},
)
]
@given("two identical core pipeline fragments")
def step_given_two_identical_fragments(context: Context) -> None:
ts = datetime(2024, 1, 1, tzinfo=UTC)
frag = _make_core_fragment(relevance_score=0.7, created_at=ts)
context.core_fragments = [frag]
context.core_fragments2 = [frag]
@given("two core pipeline fragments with different timestamps")
def step_given_two_different_timestamps(context: Context) -> None:
ts_old = datetime(2024, 1, 1, tzinfo=UTC)
ts_new = datetime(2024, 6, 1, tzinfo=UTC)
context.core_fragments = [
_make_core_fragment(
uko_node="test://old",
relevance_score=0.5,
created_at=ts_old,
),
_make_core_fragment(
uko_node="test://new",
relevance_score=0.5,
created_at=ts_new,
),
]
@given("two core pipeline fragments with identical timestamps")
def step_given_two_identical_timestamps(context: Context) -> None:
ts = datetime(2024, 1, 1, tzinfo=UTC)
context.core_fragments = [
_make_core_fragment(
uko_node="test://a",
relevance_score=0.5,
created_at=ts,
),
_make_core_fragment(
uko_node="test://b",
relevance_score=0.7,
created_at=ts,
),
]
@given("a core pipeline fragment with maximum relevance and priority")
def step_given_max_relevance_priority(context: Context) -> None:
context.core_fragments = [
_make_core_fragment(
relevance_score=1.0,
metadata={"priority": "1.0"},
)
]
@given("a core pipeline fragment with zero relevance and zero priority")
def step_given_zero_relevance_priority(context: Context) -> None:
context.core_fragments = [
_make_core_fragment(
relevance_score=0.0,
metadata={"priority": "0.0"},
)
]
@given(
"scorer configured with relevance_weight {rel:g} recency_weight {rec:g} "
"priority_weight {pri:g}"
)
def step_given_custom_scorer_weights(
context: Context, rel: float, rec: float, pri: float
) -> None:
context.custom_scorer = RelevanceRecencyPriorityScorer(
relevance_weight=rel,
recency_weight=rec,
priority_weight=pri,
)
@given('a core pipeline fragment with metadata priority "{priority}"')
def step_given_fragment_with_priority_metadata(context: Context, priority: str) -> None:
context.core_fragments = [
_make_core_fragment(
relevance_score=0.5,
metadata={"priority": priority},
)
]
@given("a core pipeline fragment with no priority metadata")
def step_given_fragment_no_priority(context: Context) -> None:
context.core_fragments = [_make_core_fragment(relevance_score=0.5)]
# ---------------------------------------------------------------------------
# RelevanceRecencyPriorityScorer — When steps
# ---------------------------------------------------------------------------
@when("I score the fragments with RelevanceRecencyPriorityScorer")
def step_score_with_rrp_scorer(context: Context) -> None:
scorer = RelevanceRecencyPriorityScorer()
context.core_scored = list(scorer.score(context.core_fragments))
@when("I score both fragment sets with RelevanceRecencyPriorityScorer")
def step_score_both_sets(context: Context) -> None:
scorer = RelevanceRecencyPriorityScorer()
context.core_scored = list(scorer.score(context.core_fragments))
context.core_scored2 = list(scorer.score(context.core_fragments2))
@when("I score the fragments with custom RelevanceRecencyPriorityScorer")
def step_score_with_custom_scorer(context: Context) -> None:
scorer = context.custom_scorer
context.core_scored = list(scorer.score(context.core_fragments))
# ---------------------------------------------------------------------------
# RelevanceRecencyPriorityScorer — Then steps
# ---------------------------------------------------------------------------
@then("{count:d} core scored fragments should be returned")
def step_scored_count_core(context: Context, count: int) -> None:
assert len(context.core_scored) == count, (
f"Expected {count}, got {len(context.core_scored)}"
)
@then('the scored fragment should have _original_relevance metadata "{val}"')
def step_scored_original_relevance_core(context: Context, val: str) -> None:
assert len(context.core_scored) >= 1
meta = context.core_scored[0].metadata
assert meta.get("_original_relevance") == val, (
f"Expected {val}, got {meta.get('_original_relevance')}"
)
@then("the scored fragment composite score should be between 0.0 and 1.0")
def step_scored_composite_range(context: Context) -> None:
assert len(context.core_scored) >= 1
score = context.core_scored[0].relevance_score
assert 0.0 <= score <= 1.0, f"Score {score} out of range"
@then("both scored fragments should have identical composite scores")
def step_scored_identical(context: Context) -> None:
assert len(context.core_scored) >= 1
assert len(context.core_scored2) >= 1
assert (
context.core_scored[0].relevance_score
== context.core_scored2[0].relevance_score
)
@then("the newer fragment should have higher recency score")
def step_newer_higher_recency(context: Context) -> None:
assert len(context.core_scored) == 2
old_recency = float(context.core_scored[0].metadata.get("_score_recency", "0"))
new_recency = float(context.core_scored[1].metadata.get("_score_recency", "0"))
assert new_recency > old_recency, (
f"Expected new ({new_recency}) > old ({old_recency})"
)
@then("all scored fragments should have recency score 1.0")
def step_all_recency_one(context: Context) -> None:
for frag in context.core_scored:
recency = float(frag.metadata.get("_score_recency", "0"))
assert abs(recency - 1.0) < 1e-6, f"Expected recency 1.0, got {recency}"
@then("the scored fragment composite score should be at most 1.0")
def step_scored_at_most_one(context: Context) -> None:
assert len(context.core_scored) >= 1
assert context.core_scored[0].relevance_score <= 1.0
@then("the scored fragment composite score should be at least 0.0")
def step_scored_at_least_zero(context: Context) -> None:
assert len(context.core_scored) >= 1
assert context.core_scored[0].relevance_score >= 0.0
@then("the scored fragment composite score should be {expected:g}")
def step_scored_exact_composite(context: Context, expected: float) -> None:
assert len(context.core_scored) >= 1
actual = context.core_scored[0].relevance_score
assert abs(actual - expected) < 1e-4, f"Expected {expected}, got {actual}"
@then('the scored fragment should have _score_priority metadata near "{val}"')
def step_scored_priority_near(context: Context, val: str) -> None:
assert len(context.core_scored) >= 1
meta = context.core_scored[0].metadata
actual = float(meta.get("_score_priority", "0"))
expected = float(val)
assert abs(actual - expected) < 0.01, f"Expected ~{expected}, got {actual}"
@then('the scored fragment should have _score_priority metadata "{val}"')
def step_scored_priority_exact(context: Context, val: str) -> None:
assert len(context.core_scored) >= 1
meta = context.core_scored[0].metadata
actual = float(meta.get("_score_priority", "0"))
expected = float(val)
assert abs(actual - expected) < 0.01, f"Expected {expected}, got {actual}"
# ---------------------------------------------------------------------------
# ConstrainedKnapsackPacker — Given steps
# ---------------------------------------------------------------------------
@given("the following core pipeline fragments:")
def step_given_core_pipeline_fragments(context: Context) -> None:
frags: list[ContextFragment] = []
for row in context.table:
frags.append(
_make_core_fragment(
uko_node=row["uko_node"],
content=row["content"],
relevance_score=float(row["score"]),
token_count=int(row["tokens"]),
detail_depth=int(row["depth"]),
)
)
context.core_fragments = frags
@given("the following core pipeline fragments with priorities:")
def step_given_core_fragments_with_priorities(context: Context) -> None:
frags: list[ContextFragment] = []
for row in context.table:
frags.append(
_make_core_fragment(
uko_node=row["uko_node"],
content=row["content"],
relevance_score=float(row["score"]),
token_count=int(row["tokens"]),
detail_depth=int(row["depth"]),
metadata={"priority": row["priority"]},
)
)
context.core_fragments = frags
@given("a core pipeline budget with max_tokens {max_t:d} and reserved_tokens {res_t:d}")
def step_given_core_budget(context: Context, max_t: int, res_t: int) -> None:
context.core_budget = ContextBudget(max_tokens=max_t, reserved_tokens=res_t)
@given("a ConstrainedKnapsackPacker with max_file_size {max_size:d}")
def step_given_packer_max_file_size(context: Context, max_size: int) -> None:
context.core_packer = ConstrainedKnapsackPacker(max_file_size=max_size)
@given("a ConstrainedKnapsackPacker with max_total_size {max_size:d}")
def step_given_packer_max_total_size(context: Context, max_size: int) -> None:
context.core_packer = ConstrainedKnapsackPacker(max_total_size=max_size)
@given("a ConstrainedKnapsackPacker with context_view max_total_size {max_size:d}")
def step_given_packer_context_view(context: Context, max_size: int) -> None:
view = ContextView(max_total_size=max_size)
context.core_packer = ConstrainedKnapsackPacker(context_view=view)
# ---------------------------------------------------------------------------
# ConstrainedKnapsackPacker — When steps
# ---------------------------------------------------------------------------
@when("I pack the fragments with ConstrainedKnapsackPacker")
def step_pack_with_constrained_packer(context: Context) -> None:
packer = getattr(context, "core_packer", ConstrainedKnapsackPacker())
budget = getattr(
context, "core_budget", ContextBudget(max_tokens=100000, reserved_tokens=0)
)
context.core_packed = list(packer.pack(context.core_fragments, budget))
# ---------------------------------------------------------------------------
# ConstrainedKnapsackPacker — Then steps
# ---------------------------------------------------------------------------
@then("{count:d} core packed fragment should be returned")
def step_packed_count_singular_core(context: Context, count: int) -> None:
assert len(context.core_packed) == count, (
f"Expected {count}, got {len(context.core_packed)}"
)
@then("{count:d} core packed fragments should be returned")
def step_packed_count_plural_core(context: Context, count: int) -> None:
assert len(context.core_packed) == count, (
f"Expected {count}, got {len(context.core_packed)}"
)
@then("the core packed total tokens should be at most {max_tokens:d}")
def step_packed_total_at_most_core(context: Context, max_tokens: int) -> None:
total = sum(f.token_count for f in context.core_packed)
assert total <= max_tokens, f"Expected <= {max_tokens}, got {total}"
@then('the core packed fragment uko_node should be "{expected}"')
def step_packed_single_node_core(context: Context, expected: str) -> None:
assert len(context.core_packed) >= 1
assert context.core_packed[0].uko_node == expected, (
f"Expected {expected}, got {context.core_packed[0].uko_node}"
)
# ---------------------------------------------------------------------------
# PriorityCoherenceOrderer — When steps
# ---------------------------------------------------------------------------
@when("I order the fragments with PriorityCoherenceOrderer")
def step_order_with_priority_orderer(context: Context) -> None:
orderer = PriorityCoherenceOrderer()
context.core_ordered = list(orderer.order(context.core_fragments))
# ---------------------------------------------------------------------------
# PriorityCoherenceOrderer — Then steps
# ---------------------------------------------------------------------------
@then("{count:d} core ordered fragment should be returned")
def step_ordered_count_singular(context: Context, count: int) -> None:
assert len(context.core_ordered) == count, (
f"Expected {count}, got {len(context.core_ordered)}"
)
@then("{count:d} core ordered fragments should be returned")
def step_ordered_count_plural(context: Context, count: int) -> None:
assert len(context.core_ordered) == count, (
f"Expected {count}, got {len(context.core_ordered)}"
)
@then('the first core ordered fragment should have uko_node "{expected}"')
def step_first_ordered_node(context: Context, expected: str) -> None:
assert len(context.core_ordered) >= 1
actual = context.core_ordered[0].uko_node
assert actual == expected, f"Expected {expected}, got {actual}"
@then('fragments from "{prefix}" should be adjacent')
def step_fragments_adjacent(context: Context, prefix: str) -> None:
matching_indices = [
i for i, f in enumerate(context.core_ordered) if f.uko_node.startswith(prefix)
]
if len(matching_indices) <= 1:
return
for i in range(len(matching_indices) - 1):
assert matching_indices[i + 1] == matching_indices[i] + 1, (
f"Fragments from {prefix} are not adjacent: {matching_indices}"
)
# ---------------------------------------------------------------------------
# Pipeline Integration — Given steps
# ---------------------------------------------------------------------------
@given("the ACMS pipeline with an ActorPhaseStrategySelector")
def step_pipeline_with_actor_selector(context: Context) -> None:
context.core_pipeline = ACMSPipeline(
strategy_selector=ActorPhaseStrategySelector(),
)
@given("the ACMS pipeline with a SpecBudgetAllocator")
def step_pipeline_with_spec_allocator(context: Context) -> None:
context.core_pipeline = ACMSPipeline(
budget_allocator=SpecBudgetAllocator(),
)
@given("the ACMS pipeline with a RelevanceRecencyPriorityScorer")
def step_pipeline_with_rrp_scorer(context: Context) -> None:
context.core_pipeline = ACMSPipeline(
scorer=RelevanceRecencyPriorityScorer(),
)
@given("the ACMS pipeline with a ConstrainedKnapsackPacker")
def step_pipeline_with_constrained_packer(context: Context) -> None:
context.core_pipeline = ACMSPipeline(
packer=ConstrainedKnapsackPacker(),
)
@given("the ACMS pipeline with a PriorityCoherenceOrderer")
def step_pipeline_with_priority_orderer(context: Context) -> None:
context.core_pipeline = ACMSPipeline(
orderer=PriorityCoherenceOrderer(),
)
# ---------------------------------------------------------------------------
# Pipeline Integration — When steps
# ---------------------------------------------------------------------------
@when("I assemble context through the core pipeline")
def step_assemble_core_pipeline(context: Context) -> None:
budget = getattr(
context, "core_budget", ContextBudget(max_tokens=100000, reserved_tokens=0)
)
payload = context.core_pipeline.assemble(
plan_id=_TEST_PLAN_ID,
fragments=context.core_fragments,
budget=budget,
)
context.core_payload = payload
@when("I assemble context through the core pipeline with budget")
def step_assemble_core_pipeline_with_budget(context: Context) -> None:
payload = context.core_pipeline.assemble(
plan_id=_TEST_PLAN_ID,
fragments=context.core_fragments,
budget=context.core_budget,
)
context.core_payload = payload
# ---------------------------------------------------------------------------
# Pipeline Integration — Then steps
# ---------------------------------------------------------------------------
@then("the core pipeline output should contain {count:d} fragment")
def step_core_pipeline_count_singular(context: Context, count: int) -> None:
assert len(context.core_payload.fragments) == count, (
f"Expected {count}, got {len(context.core_payload.fragments)}"
)
@then("the core pipeline output should contain {count:d} fragments")
def step_core_pipeline_count_plural(context: Context, count: int) -> None:
assert len(context.core_payload.fragments) == count, (
f"Expected {count}, got {len(context.core_payload.fragments)}"
)
@then("the core pipeline output fragments should have updated relevance scores")
def step_core_pipeline_scorer_applied(context: Context) -> None:
for frag in context.core_payload.fragments:
assert "_original_relevance" in frag.metadata, (
f"Fragment {frag.uko_node} missing _original_relevance metadata"
)
@then("the core pipeline output total tokens should be at most {max_tokens:d}")
def step_core_pipeline_total_at_most(context: Context, max_tokens: int) -> None:
assert context.core_payload.total_tokens <= max_tokens, (
f"Expected <= {max_tokens}, got {context.core_payload.total_tokens}"
)
@@ -0,0 +1,580 @@
"""ACMS core pipeline component implementations.
Provides the five core ACMS pipeline components that form the central
assembly logic of the context assembly pipeline:
1. **ActorPhaseStrategySelector** -- Selects context strategies based on
actor type and plan phase from the request context. Applies actor-type
and phase-specific confidence boosts to guide strategy selection.
2. **SpecBudgetAllocator** -- Computes per-strategy token budgets using the
spec formula: ``budget_i = total_budget * (confidence_i * quality_i) /
sum(confidence_j * quality_j)``. Handles edge cases including empty
candidates, zero total weight, and single-candidate scenarios.
3. **RelevanceRecencyPriorityScorer** -- Scores context fragments by a
weighted composite of relevance, recency (normalised from ``created_at``),
and priority (from fragment metadata). Produces deterministic scores for
identical inputs.
4. **ConstrainedKnapsackPacker** -- Greedy knapsack algorithm that packs
fragments within the token budget while respecting ``max_file_size`` and
``max_total_size`` byte-size constraints from a ``ContextView``.
5. **PriorityCoherenceOrderer** -- Orders packed fragments for optimal LLM
consumption by grouping related fragments (same UKO node prefix) and
ordering groups by priority then relevance.
All components satisfy the Protocol interfaces defined in ``acms_service.py``
and can be injected into ``ACMSPipeline`` via constructor dependency injection.
Based on ``docs/specification.md`` SS42630-42653.
ISSUES CLOSED: #10015
"""
from __future__ import annotations
import logging
from collections import defaultdict
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Final
from cleveragents.application.services.acms_service import (
ContextStrategy,
)
from cleveragents.domain.models.core.context_fragment import (
ContextBudget,
ContextFragment,
)
from cleveragents.domain.models.core.context_policy import ContextView
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration constants
# ---------------------------------------------------------------------------
# Default scoring weights for RelevanceRecencyPriorityScorer
DEFAULT_RELEVANCE_WEIGHT: Final[float] = 0.5
DEFAULT_RECENCY_WEIGHT: Final[float] = 0.3
DEFAULT_PRIORITY_WEIGHT: Final[float] = 0.2
# Minimum token count for a fragment to be eligible for packing
DEFAULT_MIN_FRAGMENT_TOKENS: Final[int] = 1
# Actor-type strategy preference mappings
_ACTOR_STRATEGY_PREFERENCES: Final[dict[str, list[str]]] = {
"planner": ["relevance", "arce", "tiered"],
"executor": ["tiered", "relevance", "recency"],
"reviewer": ["relevance", "recency", "arce"],
"analyst": ["arce", "temporal-archaeology", "relevance"],
"corrector": ["plan-decision-context", "relevance", "recency"],
}
# Plan-phase strategy preference mappings
_PHASE_STRATEGY_PREFERENCES: Final[dict[str, list[str]]] = {
"strategize": ["relevance", "arce"],
"execute": ["tiered", "relevance"],
"apply": ["tiered", "recency"],
"review": ["relevance", "recency"],
"correct": ["plan-decision-context", "relevance"],
}
# Confidence boost applied to preferred strategies
_PREFERENCE_BOOST: Final[float] = 0.2
# ---------------------------------------------------------------------------
# 1. ActorPhaseStrategySelector (StrategySelector protocol)
# ---------------------------------------------------------------------------
class ActorPhaseStrategySelector:
"""Select context strategies based on actor type and plan phase.
Evaluates all strategies via ``can_handle`` and applies confidence
boosts to strategies preferred by the current actor type and plan
phase. The request dict may contain:
- ``actor_type``: The type of actor making the request.
- ``plan_phase``: The current plan phase.
Implements ``StrategySelector`` protocol from ``acms_service.py``.
Based on ``docs/specification.md`` SS42630.
"""
def __init__(
self,
*,
preference_boost: float = _PREFERENCE_BOOST,
) -> None:
self._preference_boost = preference_boost
@property
def preference_boost(self) -> float:
"""Return the configured preference boost value."""
return self._preference_boost
def select(
self,
strategies: Sequence[ContextStrategy],
request: dict[str, Any],
) -> list[tuple[ContextStrategy, float]]:
"""Return (strategy, confidence) pairs sorted by priority."""
actor_type: str = request.get("actor_type", "")
plan_phase: str = request.get("plan_phase", "")
actor_preferred: list[str] = _ACTOR_STRATEGY_PREFERENCES.get(actor_type, [])
phase_preferred: list[str] = _PHASE_STRATEGY_PREFERENCES.get(plan_phase, [])
scored: list[tuple[ContextStrategy, float]] = []
for strategy in strategies:
base_confidence = strategy.can_handle(request)
if base_confidence <= 0.0:
continue
effective = base_confidence
if strategy.name in actor_preferred:
effective = min(effective + self._preference_boost, 1.0)
if strategy.name in phase_preferred:
effective = min(effective + self._preference_boost, 1.0)
scored.append((strategy, effective))
scored.sort(key=lambda x: x[1], reverse=True)
logger.debug(
"ActorPhaseStrategySelector: selected %d strategies",
len(scored),
)
return scored
# ---------------------------------------------------------------------------
# 2. SpecBudgetAllocator (BudgetAllocator protocol)
# ---------------------------------------------------------------------------
class SpecBudgetAllocator:
"""Compute per-strategy token budgets using the spec formula.
Implements the spec budget allocation formula:
budget_i = total_budget * (confidence_i * quality_i) /
sum(confidence_j * quality_j for all j)
Implements ``BudgetAllocator`` protocol from ``acms_service.py``.
Based on ``docs/specification.md`` SS42632.
"""
def __init__(self, *, min_useful_budget: int = 0) -> None:
self._min_useful_budget = min_useful_budget
@property
def min_useful_budget(self) -> int:
"""Return the minimum useful budget threshold."""
return self._min_useful_budget
@staticmethod
def _weight(strategy: ContextStrategy, confidence: float) -> float:
"""Compute allocation weight: confidence * quality_score."""
quality: float = getattr(strategy.capabilities, "quality_score", 1.0)
return confidence * quality
def allocate(
self,
candidates: list[tuple[ContextStrategy, float]],
total_budget: int,
request: Any = None,
) -> list[tuple[ContextStrategy, float, int]]:
"""Return (strategy, confidence, allocated_tokens) triples."""
if not candidates:
return []
if len(candidates) == 1:
strategy, confidence = candidates[0]
return [(strategy, confidence, total_budget)]
working = self._filter_by_min_budget(candidates, total_budget)
if not working:
best = max(candidates, key=lambda x: x[1])
return [(best[0], best[1], total_budget)]
total_weight = sum(self._weight(s, c) for s, c in working)
if total_weight <= 0.0:
return self._equal_split(working, total_budget)
return self._proportional_split(working, total_budget, total_weight)
def _filter_by_min_budget(
self,
candidates: list[tuple[ContextStrategy, float]],
total_budget: int,
) -> list[tuple[ContextStrategy, float]]:
"""Remove candidates whose proportional share < min_useful_budget."""
if self._min_useful_budget <= 0:
return list(candidates)
total_weight = sum(self._weight(s, c) for s, c in candidates)
if total_weight <= 0.0:
return list(candidates)
kept: list[tuple[ContextStrategy, float]] = []
for strategy, confidence in candidates:
w = self._weight(strategy, confidence)
share = int(total_budget * w / total_weight)
if share >= self._min_useful_budget:
kept.append((strategy, confidence))
return kept if kept else list(candidates)
def _equal_split(
self,
candidates: list[tuple[ContextStrategy, float]],
total_budget: int,
) -> list[tuple[ContextStrategy, float, int]]:
"""Split budget equally with largest-remainder distribution."""
n = len(candidates)
share = total_budget // n
remainder = total_budget - share * n
return [
(s, c, share + (1 if i < remainder else 0))
for i, (s, c) in enumerate(candidates)
]
def _proportional_split(
self,
candidates: list[tuple[ContextStrategy, float]],
total_budget: int,
total_weight: float,
) -> list[tuple[ContextStrategy, float, int]]:
"""Proportional allocation with largest-remainder rounding."""
weights = [self._weight(s, c) for s, c in candidates]
raw = [total_budget * w / total_weight for w in weights]
floors = [int(r) for r in raw]
remainder = total_budget - sum(floors)
fractions = sorted(
((r - f, i) for i, (r, f) in enumerate(zip(raw, floors, strict=True))),
reverse=True,
)
for _, i in fractions[:remainder]:
floors[i] += 1
return [(s, c, floors[i]) for i, (s, c) in enumerate(candidates)]
# ---------------------------------------------------------------------------
# 3. RelevanceRecencyPriorityScorer (FragmentScorer protocol)
# ---------------------------------------------------------------------------
class RelevanceRecencyPriorityScorer:
"""Score context fragments by relevance, recency, and priority.
Computes a weighted composite score:
composite = (relevance_weight * relevance_score
+ recency_weight * recency_norm
+ priority_weight * priority)
Produces deterministic scores for identical inputs.
Implements ``FragmentScorer`` protocol from ``acms_service.py``.
Based on ``docs/specification.md`` SS42636.
"""
def __init__(
self,
*,
relevance_weight: float = DEFAULT_RELEVANCE_WEIGHT,
recency_weight: float = DEFAULT_RECENCY_WEIGHT,
priority_weight: float = DEFAULT_PRIORITY_WEIGHT,
) -> None:
self._relevance_weight = relevance_weight
self._recency_weight = recency_weight
self._priority_weight = priority_weight
@property
def relevance_weight(self) -> float:
"""Return the configured relevance weight."""
return self._relevance_weight
@property
def recency_weight(self) -> float:
"""Return the configured recency weight."""
return self._recency_weight
@property
def priority_weight(self) -> float:
"""Return the configured priority weight."""
return self._priority_weight
def score(
self,
fragments: Sequence[ContextFragment],
) -> Sequence[ContextFragment]:
"""Return fragments re-scored with composite relevance scores."""
if not fragments:
return list(fragments)
timestamps = [f.created_at for f in fragments]
min_ts = min(timestamps)
max_ts = max(timestamps)
ts_range = (max_ts - min_ts).total_seconds()
scored: list[ContextFragment] = []
for frag in fragments:
recency_norm = self._recency_norm(frag.created_at, min_ts, ts_range)
priority = self._extract_priority(frag)
composite = self._compute_composite(
frag.relevance_score, recency_norm, priority
)
new_meta = dict(frag.metadata)
new_meta["_original_relevance"] = str(frag.relevance_score)
new_meta["_score_relevance"] = str(round(frag.relevance_score, 6))
new_meta["_score_recency"] = str(round(recency_norm, 6))
new_meta["_score_priority"] = str(round(priority, 6))
scored.append(
ContextFragment(
fragment_id=frag.fragment_id,
uko_node=frag.uko_node,
content=frag.content,
detail_depth=frag.detail_depth,
token_count=frag.token_count,
relevance_score=composite,
provenance=frag.provenance,
strategy_source=frag.strategy_source,
tier=frag.tier,
metadata=new_meta,
created_at=frag.created_at,
)
)
return scored
def _recency_norm(
self,
created_at: datetime,
min_ts: datetime,
ts_range: float,
) -> float:
"""Compute normalised recency score in [0.0, 1.0]."""
if ts_range <= 0.0:
return 1.0
elapsed = (created_at - min_ts).total_seconds()
return elapsed / ts_range
def _extract_priority(self, frag: ContextFragment) -> float:
"""Extract priority from fragment metadata, defaulting to 0.5."""
raw = frag.metadata.get("priority", "0.5")
try:
value = float(raw)
return max(0.0, min(1.0, value))
except (ValueError, TypeError):
return 0.5
def _compute_composite(
self,
relevance: float,
recency: float,
priority: float,
) -> float:
"""Compute weighted composite score clamped to [0.0, 1.0]."""
raw = (
self._relevance_weight * relevance
+ self._recency_weight * recency
+ self._priority_weight * priority
)
return max(0.0, min(1.0, round(raw, 6)))
# ---------------------------------------------------------------------------
# 4. ConstrainedKnapsackPacker (BudgetPacker protocol)
# ---------------------------------------------------------------------------
class ConstrainedKnapsackPacker:
"""Pack fragments within budget respecting max_file_size and max_total_size.
Extends the greedy knapsack algorithm with byte-size constraints
from a ``ContextView``.
Implements ``BudgetPacker`` protocol from ``acms_service.py``.
Based on ``docs/specification.md`` SS42637.
"""
def __init__(
self,
*,
max_file_size: int | None = None,
max_total_size: int | None = None,
min_fragment_tokens: int = DEFAULT_MIN_FRAGMENT_TOKENS,
context_view: ContextView | None = None,
) -> None:
self._max_file_size = max_file_size
self._max_total_size = max_total_size
self._min_fragment_tokens = min_fragment_tokens
self._context_view = context_view
@property
def max_file_size(self) -> int | None:
"""Return the configured max_file_size constraint."""
return self._max_file_size
@property
def max_total_size(self) -> int | None:
"""Return the configured max_total_size constraint."""
return self._max_total_size
@property
def min_fragment_tokens(self) -> int:
"""Return the minimum fragment token threshold."""
return self._min_fragment_tokens
def pack(
self,
fragments: Sequence[ContextFragment],
budget: ContextBudget,
) -> Sequence[ContextFragment]:
"""Pack fragments into budget with byte-size constraints."""
if not fragments:
return list(fragments)
max_file = self._max_file_size
max_total = self._max_total_size
if self._context_view is not None:
if max_file is None:
max_file = self._context_view.max_file_size
if max_total is None:
max_total = self._context_view.max_total_size
available_tokens = budget.available_tokens
eligible = [f for f in fragments if f.token_count >= self._min_fragment_tokens]
if max_file is not None:
eligible = [
f
for f in eligible
if len(f.content.encode("utf-8", errors="replace")) <= max_file
]
sorted_frags = sorted(eligible, key=lambda f: f.relevance_score, reverse=True)
packed: list[ContextFragment] = []
used_tokens = 0
used_bytes = 0
for frag in sorted_frags:
if used_tokens + frag.token_count > available_tokens:
continue
content_bytes = len(frag.content.encode("utf-8", errors="replace"))
if max_total is not None and used_bytes + content_bytes > max_total:
continue
packed.append(frag)
used_tokens += frag.token_count
used_bytes += content_bytes
return packed
# ---------------------------------------------------------------------------
# 5. PriorityCoherenceOrderer (FragmentOrderer protocol)
# ---------------------------------------------------------------------------
class PriorityCoherenceOrderer:
"""Order packed fragments for optimal LLM consumption.
Groups related fragments (same UKO node prefix) and sorts groups by
priority then relevance.
Implements ``FragmentOrderer`` protocol from ``acms_service.py``.
Based on ``docs/specification.md`` SS42648.
"""
def __init__(
self,
*,
min_shared_segments: int = 2,
) -> None:
self._min_shared_segments = min_shared_segments
@property
def min_shared_segments(self) -> int:
"""Return the minimum shared URI segments for grouping."""
return self._min_shared_segments
def order(
self,
fragments: Sequence[ContextFragment],
) -> Sequence[ContextFragment]:
"""Order fragments for optimal LLM consumption."""
if not fragments or len(fragments) <= 1:
return list(fragments)
groups: dict[str, list[ContextFragment]] = defaultdict(list)
for frag in fragments:
prefix = self._extract_prefix(frag.uko_node)
groups[prefix].append(frag)
def group_sort_key(group: list[ContextFragment]) -> tuple[float, float]:
max_priority = max(self._extract_priority(f) for f in group)
max_relevance = max(f.relevance_score for f in group)
return (-max_priority, -max_relevance)
sorted_groups = sorted(groups.values(), key=group_sort_key)
ordered: list[ContextFragment] = []
for group in sorted_groups:
sorted_group = sorted(
group,
key=lambda f: (-self._extract_priority(f), -f.relevance_score),
)
ordered.extend(sorted_group)
return ordered
def _extract_prefix(self, uko_node: str) -> str:
"""Extract the grouping prefix from a UKO node URI."""
segments = _uri_segments(uko_node)
prefix_segments = segments[: self._min_shared_segments]
return "/".join(prefix_segments) if prefix_segments else uko_node
@staticmethod
def _extract_priority(frag: ContextFragment) -> float:
"""Extract priority from fragment metadata, defaulting to 0.5."""
raw = frag.metadata.get("priority", "0.5")
try:
value = float(raw)
return max(0.0, min(1.0, value))
except (ValueError, TypeError):
return 0.5
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _uri_segments(uri: str) -> list[str]:
"""Split a UKO URI into path segments for hierarchy comparison."""
if "://" in uri:
uri = uri.split("://", 1)[1]
return [seg for seg in uri.replace("\\", "/").split("/") if seg]