DerivedMetadataService.java

package com.taxonomy.architecture.service;

import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.relations.repository.RequirementCoverageRepository;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.repository.TaxonomyRelationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Computes and persists derived graph metadata on {@link TaxonomyNode} entities.
 *
 * <p>Derived metadata includes:
 * <ul>
 *     <li>Incoming/outgoing relation counts</li>
 *     <li>Requirement coverage count</li>
 *     <li>Graph role classification (hub / leaf / bridge / isolated)</li>
 * </ul>
 *
 * <p>These fields are indexed by Hibernate Search and improve search relevance,
 * ranking quality, and enable faceted queries over structural properties.
 */
@Service
public class DerivedMetadataService {

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

    /** Nodes with ≥ this many total relations are classified as hubs. */
    private static final int HUB_THRESHOLD = 5;

    private final TaxonomyNodeRepository nodeRepository;
    private final TaxonomyRelationRepository relationRepository;
    private final RequirementCoverageRepository coverageRepository;

    public DerivedMetadataService(TaxonomyNodeRepository nodeRepository,
                                  TaxonomyRelationRepository relationRepository,
                                  RequirementCoverageRepository coverageRepository) {
        this.nodeRepository = nodeRepository;
        this.relationRepository = relationRepository;
        this.coverageRepository = coverageRepository;
    }

    /**
     * Recompute derived metadata for all taxonomy nodes.
     *
     * @return the number of nodes updated
     */
    @Transactional
    public int recomputeAll() {
        log.info("Recomputing derived metadata for all nodes…");

        Map<String, Integer> incomingCounts = new HashMap<>();
        Map<String, Integer> outgoingCounts = new HashMap<>();
        Map<String, Integer> coverageCounts = new HashMap<>();

        // Count relations
        relationRepository.findAll().forEach(rel -> {
            String srcCode = rel.getSourceNode().getCode();
            String tgtCode = rel.getTargetNode().getCode();
            outgoingCounts.merge(srcCode, 1, Integer::sum);
            incomingCounts.merge(tgtCode, 1, Integer::sum);
        });

        // Count requirement coverage
        coverageRepository.findNodeCodeCountPairs().forEach(pair -> {
            String nodeCode = (String) pair[0];
            long count = (Long) pair[1];
            coverageCounts.put(nodeCode, (int) count);
        });

        List<TaxonomyNode> allNodes = nodeRepository.findAll();
        int updated = 0;

        for (TaxonomyNode node : allNodes) {
            String code = node.getCode();
            int incoming = incomingCounts.getOrDefault(code, 0);
            int outgoing = outgoingCounts.getOrDefault(code, 0);
            int coverage = coverageCounts.getOrDefault(code, 0);
            String role = classifyRole(incoming, outgoing);

            boolean changed = node.getIncomingRelationCount() != incoming
                    || node.getOutgoingRelationCount() != outgoing
                    || node.getRequirementCoverageCount() != coverage
                    || !role.equals(node.getGraphRole());

            if (changed) {
                node.setIncomingRelationCount(incoming);
                node.setOutgoingRelationCount(outgoing);
                node.setRequirementCoverageCount(coverage);
                node.setGraphRole(role);
                updated++;
            }
        }

        nodeRepository.saveAll(allNodes);
        log.info("Derived metadata recomputed: {} nodes updated out of {} total.", updated, allNodes.size());
        return updated;
    }

    /**
     * Classify a node's graph role based on its relation counts.
     *
     * <ul>
     *     <li><b>hub</b>: ≥ {@value HUB_THRESHOLD} total relations</li>
     *     <li><b>bridge</b>: both incoming and outgoing relations, but below hub threshold</li>
     *     <li><b>leaf</b>: only incoming or only outgoing relations</li>
     *     <li><b>isolated</b>: no relations at all</li>
     * </ul>
     */
    public static String classifyRole(int incoming, int outgoing) {
        int total = incoming + outgoing;
        if (total == 0) return "isolated";
        if (total >= HUB_THRESHOLD) return "hub";
        if (incoming > 0 && outgoing > 0) return "bridge";
        return "leaf";
    }
}