AccuracyReportRenderer.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.eval;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.OptionalDouble;

/**
 * Renders an {@link AccuracyEvaluator.Evaluation} as Markdown or JSON.
 *
 * <h2>Determinism</h2>
 *
 * <p>
 * The output is a pure function of the evaluation: lines end with {@code \n} on
 * every platform (never the platform line separator), numbers are formatted with
 * {@link Locale#ROOT} to four decimal places (never a locale decimal comma), tags
 * are listed in sorted order, and the report contains no timestamp, path or host
 * information. Two runs over the same inputs are therefore byte-identical on any
 * operating system, in line with the cross-platform determinism contract of the
 * scan output.
 * </p>
 *
 * <p>
 * An undefined metric (zero denominator, see {@link AccuracyMetrics}) is rendered
 * as {@code n/a} in Markdown and as JSON {@code null}.
 * </p>
 *
 * <p>
 * This class is stateless and thread-safe.
 * </p>
 *
 * @see AccuracyEvaluator
 * @since 6.1.0
 */
public final class AccuracyReportRenderer {

    /** JSON report schema version; bump on any breaking field change. */
    public static final int JSON_SCHEMA_VERSION = 1;

    private static final char LF = '\n';
    private static final String NOT_AVAILABLE = "n/a";
    private static final String JSON_NULL = "null";
    private static final String NUMBER_FORMAT = "%.4f";
    private static final String JSON_SEP = ", ";
    private static final String EMPTY_CELL = " |";
    private static final String LEFT = "---";
    private static final String RIGHT = "---:";
    private static final int BUILDER_CAPACITY = 2048;
    private static final char[] HEX = "0123456789abcdef".toCharArray();
    private static final int CONTROL_LIMIT = 0x20;
    private static final int NIBBLE_SHIFT = 4;
    private static final int NIBBLE_MASK = 0xF;

    private AccuracyReportRenderer() {
        // static utility; no instances
    }

    /**
     * Renders the evaluation as a Markdown document.
     *
     * @param ev evaluation to render; must not be {@code null}
     * @return Markdown text with {@code \n} line endings, ending in a newline
     */
    public static String markdown(AccuracyEvaluator.Evaluation ev) {
        AccuracyMetrics.Result m = ev.metrics();
        AccuracyMetrics.Counts r = m.relevance();
        StringBuilder sb = new StringBuilder(BUILDER_CAPACITY);
        line(sb, "# MethodAtlas AI accuracy evaluation\n");

        line(sb, "## Coverage\n");
        row(sb, "Item", "Count");
        row(sb, LEFT, RIGHT);
        row(sb, "Prediction rows read", String.valueOf(ev.predictionRows()));
        row(sb, "Evaluated (labelled and predicted)", String.valueOf(ev.evaluated()));
        row(sb, "Predictions without a label", String.valueOf(ev.unlabelledPredictions()));
        row(sb, "Labelled methods without an AI prediction", String.valueOf(ev.unpredictedLabels()));
        row(sb, "Label entries matching no scanned method", String.valueOf(ev.staleLabelEntries()));
        line(sb, "");

        line(sb, "## securityRelevant\n");
        line(sb, "Samples: " + m.relevanceSamples() + "\n");
        row(sb, "", "Truth: relevant", "Truth: not relevant");
        row(sb, LEFT, RIGHT, RIGHT);
        row(sb, "**Predicted: relevant**", "TP " + r.tp(), "FP " + r.fp());
        row(sb, "**Predicted: not relevant**", "FN " + r.fn(), "TN " + r.tn());
        line(sb, "");
        row(sb, "Metric", "Value");
        row(sb, LEFT, RIGHT);
        row(sb, "Precision", format(r.precision()));
        row(sb, "Recall", format(r.recall()));
        row(sb, "F1", format(r.f1()));
        row(sb, "Accuracy", format(r.accuracy()));
        row(sb, "Support", String.valueOf(r.support()));
        line(sb, "");

        line(sb, "## Taxonomy tags\n");
        line(sb, "Samples: " + m.tagSamples() + "\n");
        row(sb, "Tag", "Precision", "Recall", "F1", "Support", "TP", "FP", "FN");
        row(sb, LEFT, RIGHT, RIGHT, RIGHT, RIGHT, RIGHT, RIGHT, RIGHT);
        for (AccuracyMetrics.TagResult t : m.tags()) {
            tagRow(sb, t.tag(), t.counts());
        }
        tagRow(sb, "**micro-average**", m.tagsMicro());
        row(sb, "**macro-average**", "", "", format(m.tagsMacroF1()), "", "", "", "");
        return sb.toString();
    }

    /**
     * Renders the evaluation as a JSON document.
     *
     * <p>
     * Keys appear in a fixed order. Undefined metrics are {@code null}.
     * </p>
     *
     * @param ev evaluation to render; must not be {@code null}
     * @return JSON text with {@code \n} line endings, ending in a newline
     */
    public static String json(AccuracyEvaluator.Evaluation ev) {
        AccuracyMetrics.Result m = ev.metrics();
        List<String> tagObjects = new ArrayList<>(m.tags().size());
        for (AccuracyMetrics.TagResult t : m.tags()) {
            tagObjects.add("    {\"tag\": " + quote(t.tag()) + JSON_SEP + countFields(t.counts(), false) + "}");
        }
        String tags = tagObjects.isEmpty() ? "[]" : "[\n" + String.join(",\n", tagObjects) + "\n  ]";

        StringBuilder sb = new StringBuilder(BUILDER_CAPACITY);
        line(sb, "{");
        line(sb, "  \"schemaVersion\": " + JSON_SCHEMA_VERSION + ",");
        line(sb, "  \"coverage\": {");
        line(sb, "    \"predictionRows\": " + ev.predictionRows() + ",");
        line(sb, "    \"evaluated\": " + ev.evaluated() + ",");
        line(sb, "    \"unlabelledPredictions\": " + ev.unlabelledPredictions() + ",");
        line(sb, "    \"unpredictedLabels\": " + ev.unpredictedLabels() + ",");
        line(sb, "    \"staleLabelEntries\": " + ev.staleLabelEntries());
        line(sb, "  },");
        line(sb, "  \"securityRelevant\": {" + countFields(m.relevance(), true) + "},");
        line(sb, "  \"tags\": " + tags + ",");
        line(sb, "  \"tagsMicro\": {" + countFields(m.tagsMicro(), false) + "},");
        line(sb, "  \"tagsMacroF1\": " + jsonNumber(m.tagsMacroF1()));
        line(sb, "}");
        return sb.toString();
    }

    /** Appends {@code text} followed by a single LF. */
    private static void line(StringBuilder sb, String text) {
        sb.append(text).append(LF);
    }

    /** Appends one Markdown table row; empty cells render as {@code | |}. */
    private static void row(StringBuilder sb, String... cells) {
        sb.append('|');
        for (String cell : cells) {
            sb.append(cell.isEmpty() ? EMPTY_CELL : " " + cell + EMPTY_CELL);
        }
        sb.append(LF);
    }

    private static void tagRow(StringBuilder sb, String name, AccuracyMetrics.Counts c) {
        row(sb, name, format(c.precision()), format(c.recall()), format(c.f1()),
                String.valueOf(c.support()), String.valueOf(c.tp()), String.valueOf(c.fp()),
                String.valueOf(c.fn()));
    }

    /** Returns the fields of a counts object without braces; {@code full} adds tn and accuracy. */
    private static String countFields(AccuracyMetrics.Counts c, boolean full) {
        List<String> fields = new ArrayList<>();
        fields.add("\"tp\": " + c.tp());
        fields.add("\"fp\": " + c.fp());
        fields.add("\"fn\": " + c.fn());
        if (full) {
            fields.add("\"tn\": " + c.tn());
        }
        fields.add("\"support\": " + c.support());
        fields.add("\"precision\": " + jsonNumber(c.precision()));
        fields.add("\"recall\": " + jsonNumber(c.recall()));
        fields.add("\"f1\": " + jsonNumber(c.f1()));
        if (full) {
            fields.add("\"accuracy\": " + jsonNumber(c.accuracy()));
        }
        return String.join(JSON_SEP, fields);
    }

    private static String format(OptionalDouble value) {
        return value.isPresent() ? String.format(Locale.ROOT, NUMBER_FORMAT, value.getAsDouble()) : NOT_AVAILABLE;
    }

    private static String jsonNumber(OptionalDouble value) {
        return value.isPresent() ? String.format(Locale.ROOT, NUMBER_FORMAT, value.getAsDouble()) : JSON_NULL;
    }

    private static String quote(String s) {
        StringBuilder sb = new StringBuilder(s.length() + 2);
        sb.append('"');
        for (int i = 0; i < s.length(); i++) {
            appendEscaped(sb, s.charAt(i));
        }
        return sb.append('"').toString();
    }

    private static void appendEscaped(StringBuilder sb, char ch) {
        switch (ch) {
            case '"' -> sb.append("\\\"");
            case '\\' -> sb.append("\\\\");
            case '\n' -> sb.append("\\n");
            case '\r' -> sb.append("\\r");
            case '\t' -> sb.append("\\t");
            default -> {
                if (ch < CONTROL_LIMIT) {
                    sb.append("\\u00").append(HEX[(ch >> NIBBLE_SHIFT) & NIBBLE_MASK]).append(HEX[ch & NIBBLE_MASK]);
                } else {
                    sb.append(ch);
                }
            }
        }
    }
}