AnalysisScoreSemantics.java

package com.taxonomy.dto;

import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

/**
 * Deterministically separates raw product suitability from comparable hierarchical relevance.
 *
 * <p>The legacy {@code scores} map remains the evidence returned by the scoring provider. Concrete
 * products retain that raw 0–100 suitability value, while generic consumers use
 * {@code effectiveScores}. A product's effective relevance is its direct family relevance
 * multiplied by its suitability:</p>
 *
 * <pre>effective = round(parent relevance × product suitability / 100)</pre>
 */
public final class AnalysisScoreSemantics {

    public static final int CURRENT_VERSION = 1;
    public static final String ROLE_PRODUCT = "PRODUCT";

    private static final int MAX_WARNINGS = 100;
    private static final String SUPPRESSED_WARNINGS_MESSAGE =
            "Additional score-semantics warnings were suppressed.";

    private AnalysisScoreSemantics() {
    }

    public static Derived derive(
            Map<String, Integer> rawScores,
            List<TaxonomyNodeDto> taxonomyTree) {
        Map<String, Integer> normalizedScores = normalizeScores(rawScores);
        Map<String, NodeContext> contexts = index(taxonomyTree);
        Map<String, AnalysisScoreDetail> details = new LinkedHashMap<>();
        Map<String, Integer> effectiveScores = new LinkedHashMap<>();
        Map<String, Integer> productSuitabilityScores = new LinkedHashMap<>();
        List<String> warnings = new ArrayList<>();

        for (Map.Entry<String, Integer> entry : normalizedScores.entrySet()) {
            String code = entry.getKey();
            int rawScore = entry.getValue();
            NodeContext context = contexts.get(code);
            AnalysisScoreKind kind;
            String parentCode = context == null ? null : context.parentCode();
            Integer parentScore = parentCode == null ? null : normalizedScores.get(parentCode);
            int effectiveRelevance = rawScore;

            if (context == null) {
                kind = AnalysisScoreKind.HIERARCHICAL_RELEVANCE;
                addWarning(warnings, "Score semantics could not resolve taxonomy node " + code
                        + "; treating its value as hierarchical relevance.");
            } else if (ROLE_PRODUCT.equals(context.analysisRole())) {
                kind = AnalysisScoreKind.PRODUCT_SUITABILITY;
                productSuitabilityScores.put(code, rawScore);
                if (parentCode == null || parentScore == null) {
                    effectiveRelevance = 0;
                    addWarning(warnings, "Concrete product " + code
                            + " has no evaluated direct family score; its effective relevance is 0.");
                } else {
                    effectiveRelevance = effectiveProductRelevance(parentScore, rawScore);
                }
            } else if (parentCode == null) {
                kind = AnalysisScoreKind.ROOT_RELEVANCE;
            } else {
                kind = AnalysisScoreKind.HIERARCHICAL_RELEVANCE;
            }

            AnalysisScoreDetail detail = new AnalysisScoreDetail(
                    code, kind, rawScore, effectiveRelevance, parentCode, parentScore);
            details.put(code, detail);
            effectiveScores.put(code, effectiveRelevance);
        }

        return new Derived(effectiveScores, productSuitabilityScores, details, warnings);
    }

    public static int effectiveProductRelevance(int familyRelevance, int suitability) {
        int family = clamp(familyRelevance);
        int product = clamp(suitability);
        return clamp((int) Math.round(family * product / 100.0));
    }

    private static void addWarning(List<String> warnings, String warning) {
        if (warnings.size() < MAX_WARNINGS) {
            warnings.add(warning);
        } else if (warnings.size() == MAX_WARNINGS) {
            warnings.add(SUPPRESSED_WARNINGS_MESSAGE);
        }
    }

    static Map<String, Integer> normalizeScores(Map<String, Integer> source) {
        if (source == null || source.isEmpty()) {
            return Map.of();
        }
        Map<String, Integer> canonicalScores = new TreeMap<>();
        for (Map.Entry<String, Integer> entry : source.entrySet()) {
            if (entry.getKey() == null || entry.getKey().isBlank()
                    || entry.getValue() == null) {
                continue;
            }
            String code = entry.getKey().strip();
            if (canonicalScores.containsKey(code)) {
                throw new IllegalArgumentException(
                        "Score map contains multiple entries for canonical node code " + code);
            }
            canonicalScores.put(code, clamp(entry.getValue()));
        }
        return new LinkedHashMap<>(canonicalScores);
    }

    private static int clamp(int value) {
        return Math.max(0, Math.min(100, value));
    }

    private static Map<String, NodeContext> index(List<TaxonomyNodeDto> roots) {
        if (roots == null || roots.isEmpty()) {
            return Map.of();
        }
        Map<String, NodeContext> result = new LinkedHashMap<>();
        Set<TaxonomyNodeDto> visited = Collections.newSetFromMap(new IdentityHashMap<>());
        Set<String> activeCodes = new LinkedHashSet<>();
        for (TaxonomyNodeDto root : roots) {
            visit(root, null, result, visited, activeCodes);
        }
        return result;
    }

    private static void visit(
            TaxonomyNodeDto node,
            String inheritedParentCode,
            Map<String, NodeContext> result,
            Set<TaxonomyNodeDto> visited,
            Set<String> activeCodes) {
        if (node == null || node.getCode() == null || node.getCode().isBlank()) {
            return;
        }
        String code = node.getCode().strip();
        if (!activeCodes.add(code)) {
            throw new IllegalArgumentException(
                    "The frozen taxonomy hierarchy contains a cycle at node code " + code);
        }
        if (!visited.add(node)) {
            activeCodes.remove(code);
            throw new IllegalArgumentException(
                    "The frozen taxonomy hierarchy reuses node " + code + " more than once");
        }
        try {
            String parentCode = firstNonBlank(node.getParentCode(), inheritedParentCode);
            String role = node.getAnalysisRole() == null
                    ? "CATEGORY" : node.getAnalysisRole().strip().toUpperCase(Locale.ROOT);
            NodeContext context = new NodeContext(parentCode, role);
            NodeContext previous = result.putIfAbsent(code, context);
            if (previous != null) {
                throw new IllegalArgumentException(
                        "The frozen taxonomy hierarchy contains duplicate node code " + code);
            }
            List<TaxonomyNodeDto> children = node.getChildren() == null
                    ? List.of() : node.getChildren();
            for (TaxonomyNodeDto child : children) {
                visit(child, code, result, visited, activeCodes);
            }
        } finally {
            activeCodes.remove(code);
        }
    }

    private static String firstNonBlank(String first, String second) {
        if (first != null && !first.isBlank()) {
            return first.strip();
        }
        return second == null || second.isBlank() ? null : second.strip();
    }

    private record NodeContext(String parentCode, String analysisRole) {
    }

    public record Derived(
            Map<String, Integer> effectiveScores,
            Map<String, Integer> productSuitabilityScores,
            Map<String, AnalysisScoreDetail> scoreDetails,
            List<String> warnings) {

        public Derived {
            effectiveScores = immutableMap(effectiveScores);
            productSuitabilityScores = immutableMap(productSuitabilityScores);
            scoreDetails = scoreDetails == null
                    ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(scoreDetails));
            warnings = warnings == null ? List.of() : List.copyOf(warnings);
        }

        private static <K, V> Map<K, V> immutableMap(Map<K, V> source) {
            return source == null
                    ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(source));
        }
    }
}