NodeLimitStep.java
package com.taxonomy.architecture.pipeline;
import com.taxonomy.dto.RequirementAnchor;
import com.taxonomy.dto.RequirementElementView;
import com.taxonomy.dto.RequirementRelationshipView;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Applies a hard output limit to the architecture-view element, anchor, and
* relationship lists.
*
* <p>When {@code maxArchitectureNodes} is positive and the element list exceeds
* that limit, endpoints of the strongest existing relationships are retained
* first. The remaining budget is filled from the already ranked element list.
* Anchors and relationships are then projected onto exactly that retained node
* set, while the complete analysis score map remains unchanged.
*
* <p>This graph-aware projection avoids turning a useful architecture into a
* disconnected collection of high-scoring nodes. It also keeps impact-relation
* generation bounded because this step continues to run before step 800.
*
* <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 enforces the caller-requested node-count
* limit. Do not disable or reorder it relative to impact-relation (step 800),
* which must run <em>after</em> this bounded graph projection.
*/
@Service
public class NodeLimitStep implements ArchitecturePipelineStep {
/** Stable pipeline step ID. */
public static final String STEP_ID = "node-limit";
private static final Comparator<String> NULL_SAFE_TEXT =
Comparator.nullsLast(Comparator.naturalOrder());
private static final Comparator<RequirementRelationshipView> RELATION_PRIORITY =
Comparator.comparingDouble(NodeLimitStep::relationEvidence).reversed()
.thenComparing(RequirementRelationshipView::getSourceCode, NULL_SAFE_TEXT)
.thenComparing(RequirementRelationshipView::getTargetCode, NULL_SAFE_TEXT)
.thenComparing(RequirementRelationshipView::getRelationType, NULL_SAFE_TEXT);
@Override
public String id() { return STEP_ID; }
@Override
public int order() { return 700; }
@Override
public ArchitecturePipelineStepDescriptor descriptor() {
return new ArchitecturePipelineStepDescriptor(id(), order(), enabledByDefault(), true);
}
/**
* Applies the node-count limit stored in {@code ctx.maxArchitectureNodes}.
* Has no effect when the limit is 0 or when the element count is within the limit.
*/
@Override
public void apply(ArchitectureViewContext ctx) {
int maxNodes = ctx.getMaxArchitectureNodes();
List<RequirementElementView> elements = ctx.getElements();
List<RequirementRelationshipView> relationships = ctx.getRelationships();
if (maxNodes <= 0 || elements.size() <= maxNodes) {
return;
}
Set<String> availableCodes = elements.stream()
.map(RequirementElementView::getNodeCode)
.collect(Collectors.toSet());
Set<String> keptCodes = selectRelationshipEndpoints(
relationships, availableCodes, maxNodes);
// Fill the remaining budget from the canonical element ranking produced
// by the preceding pipeline steps.
for (RequirementElementView element : elements) {
if (keptCodes.size() >= maxNodes) {
break;
}
keptCodes.add(element.getNodeCode());
}
List<RequirementElementView> keptElements = elements.stream()
.filter(element -> keptCodes.contains(element.getNodeCode()))
.collect(Collectors.toCollection(ArrayList::new));
List<RequirementAnchor> originalAnchors = ctx.getAnchors();
List<RequirementAnchor> keptAnchors = originalAnchors.stream()
.filter(anchor -> keptCodes.contains(anchor.getNodeCode()))
.collect(Collectors.toCollection(ArrayList::new));
int omittedAnchors = originalAnchors.size() - keptAnchors.size();
ctx.setElements(keptElements);
ctx.setAnchors(keptAnchors);
// The view receives the anchor list immediately after anchor selection.
// Replacing the context list therefore requires explicit synchronization.
ctx.getView().setAnchors(keptAnchors);
ctx.setRelationships(relationships.stream()
.filter(r -> keptCodes.contains(r.getSourceCode())
&& keptCodes.contains(r.getTargetCode()))
.collect(Collectors.toList()));
String note = "Architecture view limited to " + maxNodes + " elements.";
if (omittedAnchors > 0) {
note += " Additional anchor nodes outside this bounded view: "
+ omittedAnchors + "; their scores remain in the complete analysis.";
}
ctx.getView().getNotes().add(note);
}
private static Set<String> selectRelationshipEndpoints(
List<RequirementRelationshipView> relationships,
Set<String> availableCodes,
int maxNodes) {
List<RequirementRelationshipView> rankedRelationships =
new ArrayList<>(relationships);
rankedRelationships.sort(RELATION_PRIORITY);
Set<String> selectedCodes = new LinkedHashSet<>();
for (RequirementRelationshipView relationship : rankedRelationships) {
String sourceCode = relationship.getSourceCode();
String targetCode = relationship.getTargetCode();
if (!availableCodes.contains(sourceCode) || !availableCodes.contains(targetCode)) {
continue;
}
int missingEndpoints = 0;
if (!selectedCodes.contains(sourceCode)) {
missingEndpoints++;
}
if (!sourceCode.equals(targetCode) && !selectedCodes.contains(targetCode)) {
missingEndpoints++;
}
if (selectedCodes.size() + missingEndpoints > maxNodes) {
continue;
}
selectedCodes.add(sourceCode);
selectedCodes.add(targetCode);
}
return selectedCodes;
}
private static double relationEvidence(RequirementRelationshipView relationship) {
return Math.max(relationship.getConfidence(),
relationship.getPropagatedRelevance());
}
}