AnchorSelectionStep.java
package com.taxonomy.architecture.pipeline;
import com.taxonomy.dto.RequirementAnchor;
import com.taxonomy.pipeline.PipelineConstants;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
/**
* Selects anchor nodes from the LLM score map according to the two-tier
* threshold rules.
*
* <ol>
* <li>All nodes with score ≥ {@link PipelineConstants#ANCHOR_THRESHOLD_HIGH} are anchors.</li>
* <li>If fewer than {@link PipelineConstants#MIN_ANCHORS} qualify, fall back to the
* top-{@code MIN_ANCHORS} nodes with score ≥ {@link PipelineConstants#ANCHOR_THRESHOLD_LOW}.</li>
* </ol>
*
* <p>This step is a pure function with no external dependencies and can be
* unit-tested without a Spring context.
*
* <p><b>Core invariant</b> — this step must run first and its result determines whether
* the pipeline continues at all. Do not disable or reorder it.
*/
@Service
public class AnchorSelectionStep implements ArchitecturePipelineStep {
/** Stable pipeline step ID. */
public static final String STEP_ID = "anchor-selection";
private static final int ANCHOR_THRESHOLD_HIGH = PipelineConstants.ANCHOR_THRESHOLD_HIGH;
private static final int ANCHOR_THRESHOLD_LOW = PipelineConstants.ANCHOR_THRESHOLD_LOW;
private static final int MIN_ANCHORS = PipelineConstants.MIN_ANCHORS;
@Override
public String id() { return STEP_ID; }
@Override
public int order() { return 100; }
@Override
public ArchitecturePipelineStepDescriptor descriptor() {
return new ArchitecturePipelineStepDescriptor(id(), order(), enabledByDefault(), true);
}
/**
* Selects anchors and stores them in {@code ctx.anchors}.
*/
@Override
public void apply(ArchitectureViewContext ctx) {
ctx.setAnchors(select(ctx.getScores()));
}
/**
* Selects anchors from the given score map.
* Package-private for direct unit testing.
*/
List<RequirementAnchor> select(Map<String, Integer> scores) {
List<RequirementAnchor> highAnchors = new ArrayList<>();
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
if (entry.getValue() >= ANCHOR_THRESHOLD_HIGH) {
highAnchors.add(new RequirementAnchor(
entry.getKey(), entry.getValue(), "high direct match"));
}
}
if (highAnchors.size() >= MIN_ANCHORS) {
highAnchors.sort(Comparator.comparingInt(RequirementAnchor::getDirectScore).reversed());
return highAnchors;
}
// Fallback: collect top-MIN_ANCHORS with score >= ANCHOR_THRESHOLD_LOW
List<Map.Entry<String, Integer>> candidates = new ArrayList<>();
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
if (entry.getValue() >= ANCHOR_THRESHOLD_LOW) {
candidates.add(entry);
}
}
candidates.sort(
Comparator.<Map.Entry<String, Integer>, Integer>comparing(Map.Entry::getValue)
.reversed());
List<RequirementAnchor> anchors = new ArrayList<>();
for (int i = 0; i < Math.min(MIN_ANCHORS, candidates.size()); i++) {
Map.Entry<String, Integer> entry = candidates.get(i);
anchors.add(new RequirementAnchor(
entry.getKey(), entry.getValue(), "top candidate (fallback)"));
}
return anchors;
}
}