"""Step definitions for features/acms_pipeline_phase2.feature. Tests the ACMS pipeline Phase 2 (Fragment Fusion) components directly in-memory — no database required. """ from __future__ import annotations from typing import Any from behave import given, then, when from behave.runner import Context from pydantic import ValidationError from cleveragents.application.services.acms_phase2 import ( ContentHashDeduplicator, GreedyKnapsackPacker, MaxDepthResolver, ScorerWeights, WeightedCompositeScorer, ) from cleveragents.application.services.acms_service import ACMSPipeline from cleveragents.domain.models.core.context_fragment import ( ContextBudget, ContextFragment, FragmentProvenance, ScoredFragment, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _DEFAULT_PROVENANCE = FragmentProvenance(resource_uri="test://phase2") def _make_phase2_fragment(**kwargs: Any) -> ContextFragment: """Create a ContextFragment with sensible Phase 2 test defaults.""" kwargs.setdefault("uko_node", "test://default") kwargs.setdefault("token_count", 0) kwargs.setdefault("provenance", _DEFAULT_PROVENANCE) return ContextFragment(**kwargs) # --------------------------------------------------------------------------- # ScoredFragment — Given / When / Then # --------------------------------------------------------------------------- @given( 'a context fragment with uko_node "{uko}" and content "{content}" ' "and token_count {tokens:d} for phase2" ) def step_given_fragment_for_phase2( context: Context, uko: str, content: str, tokens: int ) -> None: context.phase2_fragment = _make_phase2_fragment( uko_node=uko, content=content, token_count=tokens ) @when("I create a ScoredFragment with composite_score {score:g} and components:") def step_create_scored_fragment(context: Context, score: float) -> None: components: dict[str, float] = {} for row in context.table: components[row["component"]] = float(row["value"]) context.scored_fragment = ScoredFragment( fragment=context.phase2_fragment, composite_score=score, score_components=components, ) @when("I create a ScoredFragment with invalid composite_score {score:g}") def step_create_scored_fragment_invalid(context: Context, score: float) -> None: context.phase2_error = None try: ScoredFragment( fragment=context.phase2_fragment, composite_score=score, score_components={}, ) except (ValidationError, ValueError) as exc: context.phase2_error = exc @then("the ScoredFragment composite_score should be {expected:g}") def step_scored_fragment_composite(context: Context, expected: float) -> None: assert context.scored_fragment.composite_score == expected @then('the ScoredFragment should have component "{name}" with value {val:g}') def step_scored_fragment_component(context: Context, name: str, val: float) -> None: assert context.scored_fragment.score_components[name] == val @then("the ScoredFragment should reference the original fragment") def step_scored_fragment_ref(context: Context) -> None: assert context.scored_fragment.fragment is context.phase2_fragment @then("a phase2 validation error should be raised") def step_phase2_validation_error(context: Context) -> None: assert context.phase2_error is not None # --------------------------------------------------------------------------- # Fragment list construction — Given steps # --------------------------------------------------------------------------- @given("the following phase2 fragments:") def step_given_phase2_fragments(context: Context) -> None: frags: list[ContextFragment] = [] for row in context.table: frags.append( _make_phase2_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.phase2_fragments = frags @given("an empty phase2 fragment list") def step_given_empty_phase2(context: Context) -> None: context.phase2_fragments = [] @given("a phase2 fragment with metadata:") def step_given_phase2_fragment_with_metadata(context: Context) -> None: row = context.table[0] meta: dict[str, str] = {} if "hierarchy_weight" in row.headings: meta["hierarchy_weight"] = row["hierarchy_weight"] if "strategy_quality" in row.headings: meta["strategy_quality"] = row["strategy_quality"] if "recency_bonus" in row.headings: meta["recency_bonus"] = row["recency_bonus"] context.phase2_fragments = [ _make_phase2_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=meta, ) ] @given("a phase2 budget with max_tokens {max_t:d} and reserved_tokens {res_t:d}") def step_given_phase2_budget(context: Context, max_t: int, res_t: int) -> None: context.phase2_budget = ContextBudget(max_tokens=max_t, reserved_tokens=res_t) @given( "scorer weights: relevance={rel:g}, hierarchy={hier:g}, " "quality={qual:g}, recency={rec:g}" ) def step_given_scorer_weights( context: Context, rel: float, hier: float, qual: float, rec: float ) -> None: context.scorer_weights = ScorerWeights( relevance=rel, hierarchy=hier, quality=qual, recency=rec ) # --------------------------------------------------------------------------- # ContentHashDeduplicator — When / Then # --------------------------------------------------------------------------- @when("I deduplicate the fragments") def step_deduplicate(context: Context) -> None: dedup = ContentHashDeduplicator() context.phase2_result = list(dedup.deduplicate(context.phase2_fragments)) @then("{count:d} fragment should remain after deduplication") def step_dedup_count_singular(context: Context, count: int) -> None: assert len(context.phase2_result) == count, ( f"Expected {count}, got {len(context.phase2_result)}" ) @then("{count:d} fragments should remain after deduplication") def step_dedup_count_plural(context: Context, count: int) -> None: assert len(context.phase2_result) == count, ( f"Expected {count}, got {len(context.phase2_result)}" ) @then("the surviving fragment should have relevance score {expected:g}") def step_surviving_relevance(context: Context, expected: float) -> None: assert len(context.phase2_result) >= 1 assert context.phase2_result[0].relevance_score == expected # --------------------------------------------------------------------------- # MaxDepthResolver — When / Then # --------------------------------------------------------------------------- @when("I resolve depth conflicts") def step_resolve_depth(context: Context) -> None: resolver = MaxDepthResolver() context.phase2_result = list(resolver.resolve(context.phase2_fragments)) @then("{count:d} fragment should remain after depth resolution") def step_depth_count_singular(context: Context, count: int) -> None: assert len(context.phase2_result) == count, ( f"Expected {count}, got {len(context.phase2_result)}" ) @then("{count:d} fragments should remain after depth resolution") def step_depth_count_plural(context: Context, count: int) -> None: assert len(context.phase2_result) == count, ( f"Expected {count}, got {len(context.phase2_result)}" ) @then("the surviving fragment should have detail depth {expected:d}") def step_surviving_depth(context: Context, expected: int) -> None: assert len(context.phase2_result) >= 1 assert context.phase2_result[0].detail_depth == expected @then('the resolved fragment order should be "{c1}", "{c2}", "{c3}"') def step_resolved_order(context: Context, c1: str, c2: str, c3: str) -> None: contents = [f.content for f in context.phase2_result] assert contents == [c1, c2, c3], f"Expected [{c1}, {c2}, {c3}], got {contents}" # --------------------------------------------------------------------------- # WeightedCompositeScorer — When / Then # --------------------------------------------------------------------------- @when("I score the fragments with default weights") def step_score_default(context: Context) -> None: scorer = WeightedCompositeScorer() context.phase2_result = list(scorer.score(context.phase2_fragments)) @when("I score the fragments with custom weights") def step_score_custom(context: Context) -> None: scorer = WeightedCompositeScorer(weights=context.scorer_weights) context.phase2_result = list(scorer.score(context.phase2_fragments)) @when("I score the fragments using score_detailed") def step_score_detailed(context: Context) -> None: scorer = WeightedCompositeScorer() context.scored_fragment_list = scorer.score_detailed(context.phase2_fragments) @then("the scored fragment should have a composite relevance score") def step_scored_has_composite(context: Context) -> None: assert len(context.phase2_result) >= 1 score = context.phase2_result[0].relevance_score assert 0.0 <= score <= 1.0 @then('the scored fragment metadata should contain original relevance "{val}"') def step_scored_original_relevance(context: Context, val: str) -> None: meta = context.phase2_result[0].metadata assert meta.get("_original_relevance") == val @then("the scored fragment relevance score should be {expected:g}") def step_scored_exact_relevance(context: Context, expected: float) -> None: assert len(context.phase2_result) >= 1 actual = context.phase2_result[0].relevance_score assert abs(actual - expected) < 1e-4, f"Expected {expected}, got {actual}" @then('the scored fragment metadata should contain component "{name}" near {val:g}') def step_scored_component_near(context: Context, name: str, val: float) -> None: meta = context.phase2_result[0].metadata key = f"_score_{name}" actual = float(meta[key]) assert abs(actual - val) < 0.01, f"Expected ~{val}, got {actual}" @then("{count:d} scored fragments should be returned") def step_scored_count(context: Context, count: int) -> None: assert len(context.phase2_result) == count @then("the scored fragment relevance score should be at most {max_val:g}") def step_scored_at_most(context: Context, max_val: float) -> None: assert context.phase2_result[0].relevance_score <= max_val @then("{count:d} ScoredFragment objects should be returned") def step_scored_fragment_objects_count(context: Context, count: int) -> None: assert len(context.scored_fragment_list) == count @then("each ScoredFragment should have a composite_score between 0.0 and 1.0") def step_scored_fragment_range(context: Context) -> None: for sf in context.scored_fragment_list: assert 0.0 <= sf.composite_score <= 1.0 @then("each ScoredFragment should have score_components") def step_scored_fragment_has_components(context: Context) -> None: for sf in context.scored_fragment_list: assert len(sf.score_components) > 0 # --------------------------------------------------------------------------- # GreedyKnapsackPacker — When / Then # --------------------------------------------------------------------------- @when("I pack the fragments into budget") def step_pack(context: Context) -> None: packer = GreedyKnapsackPacker() context.phase2_result = list( packer.pack(context.phase2_fragments, context.phase2_budget) ) @then("{count:d} fragment should be packed") def step_packed_count_singular(context: Context, count: int) -> None: assert len(context.phase2_result) == count, ( f"Expected {count}, got {len(context.phase2_result)}" ) @then("{count:d} fragments should be packed") def step_packed_count_plural(context: Context, count: int) -> None: assert len(context.phase2_result) == count, ( f"Expected {count}, got {len(context.phase2_result)}" ) @then("the packed total tokens should be at most {max_tokens:d}") def step_packed_total_at_most(context: Context, max_tokens: int) -> None: total = sum(f.token_count for f in context.phase2_result) assert total <= max_tokens, f"Expected <= {max_tokens}, got {total}" @then('the packed fragment uko_node should be "{expected}"') def step_packed_single_node(context: Context, expected: str) -> None: assert len(context.phase2_result) >= 1 assert context.phase2_result[0].uko_node == expected @then('the packed fragments should include uko_node "{expected}"') def step_packed_includes_node(context: Context, expected: str) -> None: nodes = {f.uko_node for f in context.phase2_result} assert expected in nodes, f"Expected {expected} in {nodes}" @then('the packed fragment for "{node}" should have depth {depth:d}') def step_packed_fragment_depth(context: Context, node: str, depth: int) -> None: for frag in context.phase2_result: if frag.uko_node == node: assert frag.detail_depth == depth, ( f"Expected depth {depth} for {node}, got {frag.detail_depth}" ) return msg = f"No fragment found for node {node}" raise AssertionError(msg) # --------------------------------------------------------------------------- # Pipeline Integration — Given / When / Then # --------------------------------------------------------------------------- _TEST_PLAN_ID = "01JQTESTPN00000000000000BB" @given("the ACMS pipeline with a ContentHashDeduplicator") def step_pipeline_with_dedup(context: Context) -> None: context.phase2_pipeline = ACMSPipeline( deduplicator=ContentHashDeduplicator(), ) @given("the ACMS pipeline with a MaxDepthResolver") def step_pipeline_with_resolver(context: Context) -> None: context.phase2_pipeline = ACMSPipeline( depth_resolver=MaxDepthResolver(), ) @given("the ACMS pipeline with a WeightedCompositeScorer") def step_pipeline_with_scorer(context: Context) -> None: context.phase2_pipeline = ACMSPipeline( scorer=WeightedCompositeScorer(), ) @given("the ACMS pipeline with a GreedyKnapsackPacker") def step_pipeline_with_packer(context: Context) -> None: context.phase2_pipeline = ACMSPipeline( packer=GreedyKnapsackPacker(), ) @when("I assemble context through the pipeline") def step_assemble_pipeline(context: Context) -> None: budget = getattr( context, "phase2_budget", ContextBudget(max_tokens=100000, reserved_tokens=0) ) payload = context.phase2_pipeline.assemble( plan_id=_TEST_PLAN_ID, fragments=context.phase2_fragments, budget=budget, ) context.phase2_payload = payload @when("I assemble context through the pipeline with budget") def step_assemble_pipeline_with_budget(context: Context) -> None: payload = context.phase2_pipeline.assemble( plan_id=_TEST_PLAN_ID, fragments=context.phase2_fragments, budget=context.phase2_budget, ) context.phase2_payload = payload @then("the pipeline output should contain {count:d} fragment") def step_pipeline_fragment_count_singular(context: Context, count: int) -> None: assert len(context.phase2_payload.fragments) == count, ( f"Expected {count}, got {len(context.phase2_payload.fragments)}" ) @then("the pipeline output should contain {count:d} fragments") def step_pipeline_fragment_count_plural(context: Context, count: int) -> None: assert len(context.phase2_payload.fragments) == count @then("the pipeline output fragments should have updated relevance scores") def step_pipeline_scorer_applied(context: Context) -> None: for frag in context.phase2_payload.fragments: # Scorer adds _original_relevance metadata assert "_original_relevance" in frag.metadata @then("the pipeline output total tokens should be at most {max_tokens:d}") def step_pipeline_total_at_most(context: Context, max_tokens: int) -> None: assert context.phase2_payload.total_tokens <= max_tokens