@phase2 @acms @acms_pipeline_phase2
Feature: ACMS Pipeline Phase 2 — Fragment Fusion Components
  As a CleverAgents developer
  I want production-grade Phase 2 pipeline components
  So that context assembly deduplicates, resolves depths, scores, and packs fragments

  # ===========================================================================
  # ScoredFragment — Domain Model
  # ===========================================================================

  @scored_fragment
  Scenario: Create a ScoredFragment with component breakdown
    Given a context fragment with uko_node "project://app/main.py" and content "hello" and token_count 10 for phase2
    When I create a ScoredFragment with composite_score 0.85 and components:
      | component  | value |
      | relevance  | 0.9   |
      | hierarchy  | 0.8   |
      | quality    | 0.7   |
      | recency    | 1.0   |
    Then the ScoredFragment composite_score should be 0.85
    And the ScoredFragment should have component "relevance" with value 0.9
    And the ScoredFragment should have component "hierarchy" with value 0.8
    And the ScoredFragment should have component "quality" with value 0.7
    And the ScoredFragment should have component "recency" with value 1.0
    And the ScoredFragment should reference the original fragment

  @scored_fragment @validation
  Scenario: ScoredFragment rejects composite score above 1.0
    Given a context fragment with uko_node "project://app/main.py" and content "hello" and token_count 10 for phase2
    When I create a ScoredFragment with invalid composite_score 1.5
    Then a phase2 validation error should be raised

  @scored_fragment @validation
  Scenario: ScoredFragment rejects negative composite score
    Given a context fragment with uko_node "project://app/main.py" and content "hello" and token_count 10 for phase2
    When I create a ScoredFragment with invalid composite_score -0.1
    Then a phase2 validation error should be raised

  # ===========================================================================
  # ContentHashDeduplicator
  # ===========================================================================

  @deduplicator
  Scenario: Deduplicate fragments with identical content for same UKO node
    Given the following phase2 fragments:
      | uko_node              | content    | score | tokens | depth |
      | project://app/main.py | hello world | 0.5  | 10     | 3     |
      | project://app/main.py | hello world | 0.9  | 10     | 3     |
    When I deduplicate the fragments
    Then 1 fragment should remain after deduplication
    And the surviving fragment should have relevance score 0.9

  @deduplicator
  Scenario: Deduplicate keeps distinct content for same UKO node
    Given the following phase2 fragments:
      | uko_node              | content       | score | tokens | depth |
      | project://app/main.py | hello world   | 0.5   | 10     | 3     |
      | project://app/main.py | goodbye world | 0.9   | 12     | 3     |
    When I deduplicate the fragments
    Then 2 fragments should remain after deduplication

  @deduplicator
  Scenario: Deduplicate keeps fragments from different UKO nodes
    Given the following phase2 fragments:
      | uko_node              | content     | score | tokens | depth |
      | project://app/main.py | hello world | 0.5   | 10     | 3     |
      | project://app/io.py   | hello world | 0.9   | 10     | 3     |
    When I deduplicate the fragments
    Then 2 fragments should remain after deduplication

  @deduplicator
  Scenario: Deduplicate with empty fragment list
    Given an empty phase2 fragment list
    When I deduplicate the fragments
    Then 0 fragments should remain after deduplication

  @deduplicator
  Scenario: Deduplicate three copies retains highest scored
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | abc     | 0.3   | 5      | 1     |
      | project://app/main.py | abc     | 0.7   | 5      | 1     |
      | project://app/main.py | abc     | 0.5   | 5      | 1     |
    When I deduplicate the fragments
    Then 1 fragment should remain after deduplication
    And the surviving fragment should have relevance score 0.7

  # ===========================================================================
  # MaxDepthResolver
  # ===========================================================================

  @depth_resolver
  Scenario: Resolve depth conflicts to maximum depth per node
    Given the following phase2 fragments:
      | uko_node              | content       | score | tokens | depth |
      | project://app/main.py | brief summary | 0.8   | 10     | 2     |
      | project://app/main.py | full source   | 0.8   | 100    | 9     |
    When I resolve depth conflicts
    Then 1 fragment should remain after depth resolution
    And the surviving fragment should have detail depth 9

  @depth_resolver
  Scenario: Resolve preserves fragments for distinct nodes
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | alpha   | 0.8   | 10     | 2     |
      | project://app/io.py   | beta    | 0.7   | 20     | 5     |
    When I resolve depth conflicts
    Then 2 fragments should remain after depth resolution

  @depth_resolver
  Scenario: Resolve same depth keeps higher relevance
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | alpha   | 0.3   | 10     | 5     |
      | project://app/main.py | beta    | 0.9   | 20     | 5     |
    When I resolve depth conflicts
    Then 1 fragment should remain after depth resolution
    And the surviving fragment should have relevance score 0.9

  @depth_resolver
  Scenario: Resolve with empty fragment list
    Given an empty phase2 fragment list
    When I resolve depth conflicts
    Then 0 fragments should remain after depth resolution

  @depth_resolver
  Scenario: Resolve preserves original fragment order
    Given the following phase2 fragments:
      | uko_node            | content | score | tokens | depth |
      | project://app/a.py  | aaa     | 0.5   | 10     | 3     |
      | project://app/b.py  | bbb     | 0.6   | 10     | 3     |
      | project://app/c.py  | ccc     | 0.7   | 10     | 3     |
    When I resolve depth conflicts
    Then 3 fragments should remain after depth resolution
    And the resolved fragment order should be "aaa", "bbb", "ccc"

  # ===========================================================================
  # WeightedCompositeScorer
  # ===========================================================================

  @scorer
  Scenario: Score fragments with default weights
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | hello   | 0.8   | 10     | 3     |
    When I score the fragments with default weights
    Then the scored fragment should have a composite relevance score
    And the scored fragment metadata should contain original relevance "0.8"

  @scorer
  Scenario: Score with custom weights
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | hello   | 1.0   | 10     | 3     |
    And scorer weights: relevance=1.0, hierarchy=0.0, quality=0.0, recency=0.0
    When I score the fragments with custom weights
    Then the scored fragment relevance score should be 1.0

  @scorer
  Scenario: Score with metadata-provided hierarchy weight
    Given a phase2 fragment with metadata:
      | uko_node              | content | score | tokens | depth | hierarchy_weight | strategy_quality | recency_bonus |
      | project://app/main.py | hello   | 0.8   | 10     | 3     | 0.9              | 0.7              | 1.0           |
    When I score the fragments with default weights
    Then the scored fragment metadata should contain component "hierarchy" near 0.9

  @scorer
  Scenario: Score empty fragment list
    Given an empty phase2 fragment list
    When I score the fragments with default weights
    Then 0 scored fragments should be returned

  @scorer
  Scenario: Detailed scoring returns ScoredFragment objects
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | hello   | 0.8   | 10     | 3     |
      | project://app/io.py   | world   | 0.6   | 15     | 5     |
    When I score the fragments using score_detailed
    Then 2 ScoredFragment objects should be returned
    And each ScoredFragment should have a composite_score between 0.0 and 1.0
    And each ScoredFragment should have score_components

  @scorer
  Scenario: Score composite is clamped to 0.0-1.0
    Given a phase2 fragment with metadata:
      | uko_node              | content | score | tokens | depth | hierarchy_weight | strategy_quality | recency_bonus |
      | project://app/main.py | hello   | 1.0   | 10     | 3     | 1.0              | 1.0              | 1.0           |
    When I score the fragments with default weights
    Then the scored fragment relevance score should be at most 1.0

  # ===========================================================================
  # GreedyKnapsackPacker
  # ===========================================================================

  @packer
  Scenario: Pack fragments within budget
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | alpha   | 0.9   | 100    | 5     |
      | project://app/io.py   | beta    | 0.7   | 100    | 3     |
      | project://app/util.py | gamma   | 0.5   | 100    | 2     |
    And a phase2 budget with max_tokens 250 and reserved_tokens 0
    When I pack the fragments into budget
    Then 2 fragments should be packed
    And the packed total tokens should be at most 250

  @packer
  Scenario: Pack all fragments when budget is sufficient
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | alpha   | 0.9   | 50     | 5     |
      | project://app/io.py   | beta    | 0.7   | 50     | 3     |
    And a phase2 budget with max_tokens 200 and reserved_tokens 0
    When I pack the fragments into budget
    Then 2 fragments should be packed

  @packer
  Scenario: Pack empty fragment list
    Given an empty phase2 fragment list
    And a phase2 budget with max_tokens 1000 and reserved_tokens 0
    When I pack the fragments into budget
    Then 0 fragments should be packed

  @packer
  Scenario: Pack excludes fragments below minimum token threshold
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | a       | 0.9   | 5      | 3     |
      | project://app/io.py   | beta    | 0.7   | 50     | 3     |
    And a phase2 budget with max_tokens 1000 and reserved_tokens 0
    When I pack the fragments into budget
    Then 1 fragment should be packed
    And the packed fragment uko_node should be "project://app/io.py"

  @packer
  Scenario: Pack with depth fallback uses lower-depth alternative
    Given the following phase2 fragments:
      | uko_node              | content          | score | tokens | depth |
      | project://app/main.py | full source code | 0.9   | 500    | 9     |
      | project://app/main.py | brief summary    | 0.5   | 50     | 2     |
      | project://app/io.py   | io module        | 0.7   | 100    | 5     |
    And a phase2 budget with max_tokens 200 and reserved_tokens 0
    When I pack the fragments into budget
    Then the packed fragments should include uko_node "project://app/main.py"
    And the packed fragment for "project://app/main.py" should have depth 2

  @packer
  Scenario: Pack respects reserved tokens
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | alpha   | 0.9   | 100    | 5     |
      | project://app/io.py   | beta    | 0.7   | 100    | 3     |
    And a phase2 budget with max_tokens 250 and reserved_tokens 100
    When I pack the fragments into budget
    Then 1 fragment should be packed
    And the packed total tokens should be at most 150

  @packer
  Scenario: Pack prefers higher scored fragments
    Given the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | alpha   | 0.3   | 100    | 5     |
      | project://app/io.py   | beta    | 0.9   | 100    | 3     |
    And a phase2 budget with max_tokens 150 and reserved_tokens 0
    When I pack the fragments into budget
    Then 1 fragment should be packed
    And the packed fragment uko_node should be "project://app/io.py"

  @packer
  Scenario: Single fragment over entire budget is excluded
    Given the following phase2 fragments:
      | uko_node              | content       | score | tokens | depth |
      | project://app/main.py | huge content  | 0.9   | 10000  | 9     |
    And a phase2 budget with max_tokens 100 and reserved_tokens 0
    When I pack the fragments into budget
    Then 0 fragments should be packed

  # ===========================================================================
  # Pipeline Integration — DI injection of Phase 2 components
  # ===========================================================================

  @pipeline_integration
  Scenario: Inject ContentHashDeduplicator into pipeline
    Given the ACMS pipeline with a ContentHashDeduplicator
    And the following phase2 fragments:
      | uko_node              | content     | score | tokens | depth |
      | project://app/main.py | hello world | 0.5   | 10     | 3     |
      | project://app/main.py | hello world | 0.9   | 10     | 3     |
    When I assemble context through the pipeline
    Then the pipeline output should contain 1 fragment

  @pipeline_integration
  Scenario: Inject MaxDepthResolver into pipeline
    Given the ACMS pipeline with a MaxDepthResolver
    And the following phase2 fragments:
      | uko_node              | content  | score | tokens | depth |
      | project://app/main.py | brief    | 0.8   | 10     | 2     |
      | project://app/main.py | detailed | 0.8   | 50     | 9     |
    When I assemble context through the pipeline
    Then the pipeline output should contain 1 fragment

  @pipeline_integration
  Scenario: Inject WeightedCompositeScorer into pipeline
    Given the ACMS pipeline with a WeightedCompositeScorer
    And the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | hello   | 0.8   | 10     | 3     |
    When I assemble context through the pipeline
    Then the pipeline output fragments should have updated relevance scores

  @pipeline_integration
  Scenario: Inject GreedyKnapsackPacker into pipeline
    Given the ACMS pipeline with a GreedyKnapsackPacker
    And the following phase2 fragments:
      | uko_node              | content | score | tokens | depth |
      | project://app/main.py | alpha   | 0.9   | 100    | 5     |
      | project://app/io.py   | beta    | 0.7   | 100    | 3     |
      | project://app/util.py | gamma   | 0.5   | 100    | 2     |
    And a phase2 budget with max_tokens 250 and reserved_tokens 0
    When I assemble context through the pipeline with budget
    Then the pipeline output total tokens should be at most 250
