ImpactRelationStep.java

package com.taxonomy.architecture.pipeline;

import com.taxonomy.architecture.service.ImpactEndpointSelector;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.dto.RelationHypothesisDto;
import com.taxonomy.dto.RelationOrigin;
import com.taxonomy.dto.RequirementElementView;
import com.taxonomy.dto.RequirementRelationshipView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * Generates concrete impact relations between leaf nodes from different taxonomy
 * categories, and emits derived impact relations as {@link RelationHypothesisDto}
 * entries so they flow through the Accept/Reject UI.
 *
 * <p>For each trace (root-to-root) relation, this step finds the most qualified
 * leaf nodes in each endpoint's category and creates a derived impact relation
 * between them. This ensures the final architecture view prioritises meaningful
 * cross-category connections over abstract root-level propagation relations.
 *
 * <p>After generating impact relations, the full relationship list is ranked so
 * that cross-category leaf-to-leaf relations appear before root-level ones.
 *
 * <p><b>Safe extension point</b> — this step may be replaced or augmented to
 * change how cross-category impact relations are derived, provided the overall
 * relationship list remains valid on exit.
 */
@Service
public class ImpactRelationStep implements ArchitecturePipelineStep {

    /** Stable pipeline step ID. */
    public static final String STEP_ID = "impact-relation";

    private static final Logger log = LoggerFactory.getLogger(ImpactRelationStep.class);

    /** Semantic strength/order used only after priority and measured relevance tie. */
    private static final Map<String, Integer> RELATION_TYPE_ORDER = Map.of(
            "REALIZES", 10,
            "SUPPORTS", 20,
            "FULFILLS", 30,
            "USES", 40,
            "DEPENDS_ON", 50);

    /** Canonical architecture-layer tie breaker, independent of persistence order. */
    private static final Map<String, Integer> ROOT_ORDER = Map.of(
            "CP", 10,
            "BP", 20,
            "BR", 21,
            "CR", 30,
            "CI", 31,
            "UA", 40,
            "IP", 50,
            "CO", 60);

    private static final Comparator<String> NULL_SAFE_TEXT =
            Comparator.nullsLast(Comparator.naturalOrder());

    private final TaxonomyNodeRepository nodeRepository;

    public ImpactRelationStep(TaxonomyNodeRepository nodeRepository) {
        this.nodeRepository = nodeRepository;
    }

    @Override
    public String id() { return STEP_ID; }

    @Override
    public int order() { return 800; }

    @Override
    public void apply(ArchitectureViewContext ctx) {
        generateImpactRelations(ctx.getElements(), ctx.getRelationships());
        emitImpactHypotheses(ctx.getRelationships(), ctx.getProvisionalRelations());
    }

    private void generateImpactRelations(List<RequirementElementView> elements,
                                          List<RequirementRelationshipView> relationships) {
        // Group leaf elements by their taxonomy root
        Map<String, List<RequirementElementView>> leafByRoot = new java.util.LinkedHashMap<>();
        for (RequirementElementView el : elements) {
            String code = el.getNodeCode();
            if (!code.contains("-")) continue;
            String root = code.substring(0, code.indexOf('-'));
            leafByRoot.computeIfAbsent(root, k -> new ArrayList<>()).add(el);
        }

        List<RequirementRelationshipView> impactRelations = new ArrayList<>();
        Set<String> impactSignatures = new LinkedHashSet<>();
        ImpactEndpointSelector endpointSelector = new ImpactEndpointSelector();

        for (RequirementRelationshipView trace : relationships) {
            String srcRoot = rootOf(trace.getSourceCode());
            String tgtRoot = rootOf(trace.getTargetCode());
            if (srcRoot == null || tgtRoot == null) continue;
            if (srcRoot.equals(tgtRoot)) continue;

            List<RequirementElementView> srcLeaves = leafByRoot.getOrDefault(srcRoot, List.of());
            List<RequirementElementView> tgtLeaves = leafByRoot.getOrDefault(tgtRoot, List.of());
            if (srcLeaves.isEmpty() || tgtLeaves.isEmpty()) continue;

            List<RequirementElementView> srcEndpoints = endpointSelector.selectEndpoints(srcLeaves);
            List<RequirementElementView> tgtEndpoints = endpointSelector.selectEndpoints(tgtLeaves);

            for (RequirementElementView src : srcEndpoints) {
                for (RequirementElementView tgt : tgtEndpoints) {
                    String sig = src.getNodeCode() + "->" + tgt.getNodeCode()
                            + ":" + trace.getRelationType();
                    if (!impactSignatures.add(sig)) continue;

                    RequirementRelationshipView impact = new RequirementRelationshipView();
                    impact.setSourceCode(src.getNodeCode());
                    impact.setTargetCode(tgt.getNodeCode());
                    impact.setRelationType(trace.getRelationType());
                    impact.setPropagatedRelevance(
                            Math.min(src.getRelevance(), tgt.getRelevance()));
                    impact.setHopDistance(0);
                    impact.setIncludedBecause("impact: " + src.getNodeCode() + " → "
                            + tgt.getNodeCode() + " (derived from " + trace.getSourceCode()
                            + " → " + trace.getTargetCode() + ")");
                    impact.setOrigin(RelationOrigin.IMPACT_DERIVED);
                    impact.setConfidence(Math.min(src.getRelevance(), tgt.getRelevance()));
                    impact.setDerivationReason("Cross-category leaf-to-leaf: "
                            + src.getNodeCode() + " → " + tgt.getNodeCode());
                    impactRelations.add(impact);
                }
            }
        }

        if (!impactRelations.isEmpty()) {
            relationships.addAll(impactRelations);
            log.info("Generated {} impact relation(s) from {} trace relation(s)",
                    impactRelations.size(), relationships.size() - impactRelations.size());
        }

        rankRelationships(relationships);
    }

    /**
     * Emits derived impact relations as {@link RelationHypothesisDto} entries into
     * the provisional relations list, so they flow through the existing Accept/Reject
     * UI and can be evaluated by the LLM.
     *
     * <p>Only impact-category relations that don't already exist as confirmed or
     * provisional relations are emitted.
     */
    private void emitImpactHypotheses(List<RequirementRelationshipView> relationships,
                                      List<RelationHypothesisDto> provisionalRelations) {
        if (provisionalRelations == null) return;

        Set<String> existingSignatures = new LinkedHashSet<>();
        for (RelationHypothesisDto h : provisionalRelations) {
            existingSignatures.add(h.getSourceCode() + "->" + h.getTargetCode()
                    + ":" + h.getRelationType());
        }

        for (RequirementRelationshipView rel : relationships) {
            if (!RequirementRelationshipView.CATEGORY_IMPACT.equals(rel.getRelationCategory())) {
                continue;
            }
            String sig = rel.getSourceCode() + "->" + rel.getTargetCode()
                    + ":" + rel.getRelationType();
            if (!existingSignatures.add(sig)) continue;

            String srcTitle = nodeRepository.findByCode(rel.getSourceCode())
                    .map(TaxonomyNode::getNameEn).orElse(rel.getSourceCode());
            String tgtTitle = nodeRepository.findByCode(rel.getTargetCode())
                    .map(TaxonomyNode::getNameEn).orElse(rel.getTargetCode());

            RelationHypothesisDto hyp = new RelationHypothesisDto(
                    rel.getSourceCode(), srcTitle,
                    rel.getTargetCode(), tgtTitle,
                    rel.getRelationType(),
                    rel.getPropagatedRelevance(),
                    rel.getIncludedBecause());
            provisionalRelations.add(hyp);
        }
    }

    /**
     * Sorts relationships by priority:
     * <ol>
     *   <li>Cross-category leaf-to-leaf impact relations</li>
     *   <li>Same-category leaf-to-leaf relations</li>
     *   <li>Relations involving at least one leaf node</li>
     *   <li>Root-level propagation relations (non-seed)</li>
     *   <li>Seed-origin root-to-root relations (structural context)</li>
     * </ol>
     * Within each tier, relations are sorted by confidence/relevance descending,
     * then by stable semantic tie breakers. The final tie breakers are required:
     * Java's stable sort cannot provide deterministic output when the input list
     * came from an unordered database query or hash-based intermediate structure.
     */
    static void rankRelationships(List<RequirementRelationshipView> relationships) {
        relationships.sort(Comparator
                .comparingInt(ImpactRelationStep::relationPriority)
                .thenComparing(Comparator.comparingDouble(
                        RequirementRelationshipView::getConfidence).reversed())
                .thenComparing(Comparator.comparingDouble(
                        RequirementRelationshipView::getPropagatedRelevance).reversed())
                .thenComparingInt(rel -> relationTypeOrder(rel.getRelationType()))
                .thenComparingInt(rel -> rootOrder(rel.getSourceCode()))
                .thenComparingInt(rel -> rootOrder(rel.getTargetCode()))
                .thenComparing(RequirementRelationshipView::getSourceCode, NULL_SAFE_TEXT)
                .thenComparing(RequirementRelationshipView::getTargetCode, NULL_SAFE_TEXT)
                .thenComparing(RequirementRelationshipView::getRelationType, NULL_SAFE_TEXT)
                .thenComparing(RequirementRelationshipView::getIncludedBecause, NULL_SAFE_TEXT));
    }

    private static int relationPriority(RequirementRelationshipView rel) {
        boolean srcIsLeaf = rel.getSourceCode() != null && rel.getSourceCode().contains("-");
        boolean tgtIsLeaf = rel.getTargetCode() != null && rel.getTargetCode().contains("-");
        boolean isCrossCategory = !Objects.equals(rootOf(rel.getSourceCode()), rootOf(rel.getTargetCode()));
        boolean isSeed = RequirementRelationshipView.CATEGORY_SEED.equals(rel.getRelationCategory());

        if (srcIsLeaf && tgtIsLeaf && isCrossCategory) return 1;
        if (srcIsLeaf && tgtIsLeaf) return 2;
        if (srcIsLeaf || tgtIsLeaf) return 3;
        if (isSeed) return 5;
        return 4;
    }

    private static int relationTypeOrder(String relationType) {
        return RELATION_TYPE_ORDER.getOrDefault(relationType, Integer.MAX_VALUE);
    }

    private static int rootOrder(String code) {
        return ROOT_ORDER.getOrDefault(rootOf(code), Integer.MAX_VALUE);
    }

    /**
     * Returns the taxonomy root code for a node code.
     * For leaf codes like "CP-1023" returns "CP"; for root codes like "CP" returns "CP".
     */
    static String rootOf(String code) {
        if (code == null) return null;
        int dash = code.indexOf('-');
        return dash >= 0 ? code.substring(0, dash) : code;
    }
}