001package gudusoft.gsqlparser.demos.prettyscore;
002
003import gudusoft.gsqlparser.pp.score.PrettyScore;
004import gudusoft.gsqlparser.pp.score.json.JsonIn;
005
006import java.io.File;
007import java.io.IOException;
008import java.nio.charset.Charset;
009import java.nio.file.Files;
010import java.util.ArrayList;
011import java.util.Collections;
012import java.util.Comparator;
013import java.util.LinkedHashMap;
014import java.util.LinkedHashSet;
015import java.util.List;
016import java.util.Locale;
017import java.util.Map;
018import java.util.Random;
019import java.util.Set;
020import java.util.TreeMap;
021
022/**
023 * Operations over saved run records: finding-level diff between two runs,
024 * baseline regression check, corpus backlog (Markdown) and blind A/B
025 * acceptance samples. All keyed on {@code file|engine|source} and the
026 * position-independent finding fingerprint so a reformat does not break the
027 * alignment.
028 */
029public final class RunTools {
030
031    private RunTools() {
032    }
033
034    @SuppressWarnings("unchecked")
035    public static Map<String, Object> loadRun(File f) throws IOException {
036        return JsonIn.parseObject(new String(Files.readAllBytes(f.toPath()), Charset.forName("UTF-8")));
037    }
038
039    @SuppressWarnings("unchecked")
040    static List<Map<String, Object>> entries(Map<String, Object> run) {
041        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
042        for (Object o : JsonIn.arr(run, "entries")) out.add((Map<String, Object>) o);
043        return out;
044    }
045
046    static String key(Map<String, Object> e) {
047        return e.get("file") + "|" + e.get("engine") + "|" + e.get("source");
048    }
049
050    /** Fixable, counted findings of an entry as {@code fingerprint -> finding}. */
051    @SuppressWarnings("unchecked")
052    static Map<String, Map<String, Object>> fingerprints(Map<String, Object> e) {
053        Map<String, Map<String, Object>> out = new LinkedHashMap<String, Map<String, Object>>();
054        for (Object o : JsonIn.arr(e, "findings")) {
055            Map<String, Object> f = (Map<String, Object>) o;
056            if (!Boolean.TRUE.equals(f.get("fixable"))) continue;
057            String fp = String.valueOf(f.get("fingerprint"));
058            // several findings can share a fingerprint (e.g. two overflow lines in one predicate); suffix them
059            String k = fp;
060            int n = 2;
061            while (out.containsKey(k)) k = fp + "#" + (n++);
062            out.put(k, f);
063        }
064        return out;
065    }
066
067    /** Result of {@link #diffRuns}. */
068    public static final class Diff {
069        public final List<String> fixed = new ArrayList<String>();
070        public final List<String> added = new ArrayList<String>();
071        public final List<String> persisting = new ArrayList<String>();
072        /** Entries present in A but errored or absent in B: their findings are neither fixed nor persisting. */
073        public final List<String> unavailable = new ArrayList<String>();
074        public int newMajor;
075        public final StringBuilder text = new StringBuilder();
076    }
077
078    public static Diff diffRuns(Map<String, Object> a, Map<String, Object> b) {
079        Diff d = new Diff();
080        Map<String, Map<String, Object>> ea = new LinkedHashMap<String, Map<String, Object>>();
081        for (Map<String, Object> e : entries(a)) ea.put(key(e), e);
082        Map<String, Map<String, Object>> eb = new LinkedHashMap<String, Map<String, Object>>();
083        for (Map<String, Object> e : entries(b)) eb.put(key(e), e);
084        Set<String> keys = new LinkedHashSet<String>(ea.keySet());
085        keys.addAll(eb.keySet());
086        for (String k : keys) {
087            Map<String, Object> ebk = eb.get(k);
088            if (ebk == null || ebk.containsKey("error")) {
089                d.unavailable.add(k + (ebk == null ? " (missing in B)" : " (B error: " + ebk.get("error") + ")"));
090                continue;
091            }
092            Map<String, Map<String, Object>> fa = ea.containsKey(k) ? fingerprints(ea.get(k)) : new LinkedHashMap<String, Map<String, Object>>();
093            Map<String, Map<String, Object>> fb = fingerprints(ebk);
094            for (String fp : fa.keySet()) {
095                if (fb.containsKey(fp)) d.persisting.add(k + " " + fp);
096                else d.fixed.add(k + " " + fp);
097            }
098            for (Map.Entry<String, Map<String, Object>> e : fb.entrySet()) {
099                if (fa.containsKey(e.getKey())) continue;
100                String sev = String.valueOf(e.getValue().get("severity"));
101                d.added.add(k + " " + e.getKey() + " [" + sev + "] " + e.getValue().get("evidence"));
102                if ("major".equals(sev) || "bug".equals(sev)) d.newMajor++;
103            }
104        }
105        d.text.append(String.format(Locale.ROOT, "fixed %d, new %d (%d major/bug), persisting %d, unavailable %d%n",
106            d.fixed.size(), d.added.size(), d.newMajor, d.persisting.size(), d.unavailable.size()));
107        summaryLine(d.text, "A", a);
108        summaryLine(d.text, "B", b);
109        if (!d.fixed.isEmpty()) { d.text.append("FIXED:\n"); for (String s : d.fixed) d.text.append("  ").append(s).append('\n'); }
110        if (!d.added.isEmpty()) { d.text.append("NEW:\n"); for (String s : d.added) d.text.append("  ").append(s).append('\n'); }
111        if (!d.unavailable.isEmpty()) { d.text.append("UNAVAILABLE:\n"); for (String s : d.unavailable) d.text.append("  ").append(s).append('\n'); }
112        if (!d.persisting.isEmpty()) { d.text.append("PERSISTING:\n"); for (String s : d.persisting) d.text.append("  ").append(s).append('\n'); }
113        return d;
114    }
115
116    @SuppressWarnings("unchecked")
117    static void summaryLine(StringBuilder sb, String label, Map<String, Object> run) {
118        Map<String, Object> summary = JsonIn.obj(run, "summary");
119        sb.append(label).append(": ");
120        for (Map.Entry<String, Object> e : summary.entrySet()) {
121            Map<String, Object> s = (Map<String, Object>) e.getValue();
122            if (s.containsKey("mean_ratio")) {
123                sb.append(String.format(Locale.ROOT, "%s mean_ratio=%.3f min=%.3f gate_fail=%s; ", e.getKey(),
124                    JsonIn.dbl(s, "mean_ratio", 0), JsonIn.dbl(s, "min_ratio", 0), s.get("gate_failures")));
125            } else if (s.containsKey("pp2_recovery_ratio")) {
126                sb.append(String.format(Locale.ROOT, "degraded pp2_recovery_ratio=%.3f; ", JsonIn.dbl(s, "pp2_recovery_ratio", 0)));
127            }
128        }
129        sb.append('\n');
130    }
131
132    /** Baseline check: mean ratio must not drop by more than {@code maxDrop}; no new gate failures. Returns problems (empty = ok). */
133    @SuppressWarnings("unchecked")
134    public static List<String> checkBaseline(Map<String, Object> baseline, Map<String, Object> current, double maxDrop) {
135        List<String> problems = new ArrayList<String>();
136        for (String k : new String[]{"version", "vendor", "profile", "calibration", "seed", "degraded_samples", "engines", "ast"}) {
137            Object b = baseline.get(k), c = current.get(k);
138            if (b == null || c == null || !String.valueOf(b).equals(String.valueOf(c))) {
139                problems.add("run identity differs on " + k + ": baseline " + b + ", current " + c);
140            }
141        }
142        if (!problems.isEmpty()) return problems; // incomparable runs: do not read scores into it
143        Map<String, Object> sb = JsonIn.obj(baseline, "summary");
144        Map<String, Object> sc = JsonIn.obj(current, "summary");
145        for (Map.Entry<String, Object> e : sb.entrySet()) {
146            Map<String, Object> b = (Map<String, Object>) e.getValue();
147            Map<String, Object> c = (Map<String, Object>) sc.get(e.getKey());
148            if (c == null) { problems.add("engine " + e.getKey() + " missing from current run"); continue; }
149            String ratioKey = b.containsKey("mean_ratio") ? "mean_ratio" : "pp2_recovery_ratio";
150            double rb = JsonIn.dbl(b, ratioKey, 0), rc = JsonIn.dbl(c, ratioKey, 0);
151            if (rb - rc > maxDrop) {
152                problems.add(String.format(Locale.ROOT, "%s %s dropped %.3f -> %.3f (max drop %.3f)", e.getKey(), ratioKey, rb, rc, maxDrop));
153            }
154        }
155        Map<String, Map<String, Object>> eb = new LinkedHashMap<String, Map<String, Object>>();
156        for (Map<String, Object> e : entries(baseline)) eb.put(key(e), e);
157        Set<String> currentKeys = new LinkedHashSet<String>();
158        for (Map<String, Object> e : entries(current)) currentKeys.add(key(e));
159        for (String k : eb.keySet()) {
160            if (!currentKeys.contains(k)) problems.add("entry missing from current run: " + k);
161        }
162        for (Map<String, Object> e : entries(current)) {
163            Map<String, Object> b = eb.get(key(e));
164            if (b == null) {
165                if (e.containsKey("error")) problems.add("new error: " + key(e) + " " + e.get("error"));
166                else problems.add("entry not in baseline: " + key(e));
167                continue;
168            }
169            if (Boolean.TRUE.equals(b.get("gate_passed")) && e.containsKey("gate_passed") && !Boolean.TRUE.equals(e.get("gate_passed"))) {
170                problems.add("new gate failure: " + key(e));
171            }
172            if (!b.containsKey("error") && e.containsKey("error")) {
173                problems.add("new error: " + key(e) + " " + e.get("error"));
174            }
175        }
176        return problems;
177    }
178
179    /** Corpus-level improvement backlog as Markdown, aggregated by rule over fixable findings. */
180    @SuppressWarnings("unchecked")
181    public static String backlog(Map<String, Object> run, String engineFilter) {
182        Map<String, List<Object[]>> byRule = new TreeMap<String, List<Object[]>>(); // rule -> [{gain, file, line, evidence}]
183        Map<String, Set<String>> filesHit = new TreeMap<String, Set<String>>();
184        int files = 0;
185        for (Map<String, Object> e : entries(run)) {
186            if (!"clean".equals(e.get("source")) || e.containsKey("error")) continue;
187            if (engineFilter != null && !engineFilter.equals(e.get("engine"))) continue;
188            files++;
189            double attainable = JsonIn.dbl(e, "attainable", 100.0);
190            if (attainable <= 0) attainable = 100.0;
191            for (Object o : JsonIn.arr(e, "findings")) {
192                Map<String, Object> f = (Map<String, Object>) o;
193                if (!Boolean.TRUE.equals(f.get("fixable"))) continue;
194                String rule = String.valueOf(f.get("rule"));
195                List<Object[]> l = byRule.get(rule);
196                if (l == null) { l = new ArrayList<Object[]>(); byRule.put(rule, l); filesHit.put(rule, new LinkedHashSet<String>()); }
197                // gain in ratio points (score points / the entry's attainable), the quantity the loop tracks
198                l.add(new Object[]{JsonIn.dbl(f, "gain", 0) / attainable, e.get("file"), f.get("line"), f.get("evidence")});
199                filesHit.get(rule).add(key(e));
200            }
201        }
202        List<Map.Entry<String, List<Object[]>>> rows = new ArrayList<Map.Entry<String, List<Object[]>>>(byRule.entrySet());
203        final Map<String, Double> totals = new LinkedHashMap<String, Double>();
204        for (Map.Entry<String, List<Object[]>> r : rows) {
205            double t = 0;
206            for (Object[] x : r.getValue()) t += (Double) x[0];
207            totals.put(r.getKey(), t);
208        }
209        Collections.sort(rows, new Comparator<Map.Entry<String, List<Object[]>>>() {
210            @Override
211            public int compare(Map.Entry<String, List<Object[]>> a, Map.Entry<String, List<Object[]>> b) {
212                return Double.compare(totals.get(b.getKey()), totals.get(a.getKey()));
213            }
214        });
215        StringBuilder sb = new StringBuilder();
216        sb.append("# PrettyScore backlog — ").append(run.get("vendor")).append(" / ").append(run.get("profile"))
217            .append(engineFilter == null ? "" : " / " + engineFilter).append(" (").append(run.get("calibration")).append(")\n\n");
218        sb.append("Entries (file x engine): ").append(files).append(". Rows sorted by estimated corpus gain; unfixable findings excluded.\n\n");
219        sb.append("| rank | rule | files_hit / total | total_ratio_gain | est_gain (mean ratio/entry) | target | examples |\n");
220        sb.append("|---|---|---|---|---|---|---|\n");
221        int rank = 1;
222        for (Map.Entry<String, List<Object[]>> r : rows) {
223            List<Object[]> l = r.getValue();
224            Collections.sort(l, new Comparator<Object[]>() {
225                @Override public int compare(Object[] a, Object[] b) { return Double.compare((Double) b[0], (Double) a[0]); }
226            });
227            double total = totals.get(r.getKey());
228            String ex = example(l.get(0)) + "<br>" + example(l.get(l.size() / 2)) + "<br>" + example(l.get(l.size() - 1));
229            sb.append(String.format(Locale.ROOT, "| %d | %s | %d / %d | %.3f | %.4f | %s | %s |%n", rank++, r.getKey(),
230                filesHit.get(r.getKey()).size(), files, total, files == 0 ? 0 : total / files,
231                PrettyScore.targetFor(r.getKey()).replace("|", "/"), ex.replace("|", "/")));
232        }
233        return sb.toString();
234    }
235
236    private static String example(Object[] x) {
237        String ev = String.valueOf(x[3]);
238        if (ev.length() > 80) ev = ev.substring(0, 77) + "...";
239        return "`" + x[1] + ":" + x[2] + "` " + ev.replace("`", "'");
240    }
241
242    /**
243     * Write {@code n} anonymised A/B pairs from two runs that kept their
244     * formatted text: {@code NN-left.sql} / {@code NN-right.sql} with random
245     * side assignment, plus {@code key.csv} (which side is run B) for the
246     * person tallying the votes.
247     */
248    public static int acceptanceSample(Map<String, Object> a, Map<String, Object> b, int n, long seed, File outDir) throws IOException {
249        Map<String, Map<String, Object>> eb = new LinkedHashMap<String, Map<String, Object>>();
250        for (Map<String, Object> e : entries(b)) eb.put(key(e), e);
251        List<String[]> candidates = new ArrayList<String[]>();
252        for (Map<String, Object> e : entries(a)) {
253            Map<String, Object> other = eb.get(key(e));
254            if (other == null) continue;
255            String ta = JsonIn.str(e, "formatted", null);
256            String tb = JsonIn.str(other, "formatted", null);
257            if (ta == null || tb == null || ta.equals(tb)) continue;
258            candidates.add(new String[]{key(e), ta, tb});
259        }
260        if (candidates.isEmpty()) return 0;
261        Random rnd = new Random(seed);
262        Collections.shuffle(candidates, rnd);
263        if (!outDir.isDirectory() && !outDir.mkdirs()) throw new IOException("cannot create " + outDir);
264        StringBuilder keyCsv = new StringBuilder("pair,source,after_side\n");
265        int written = 0;
266        for (int i = 0; i < candidates.size() && i < n; i++) {
267            String[] c = candidates.get(i);
268            boolean afterLeft = rnd.nextBoolean();
269            String id = String.format(Locale.ROOT, "%02d", i + 1);
270            Files.write(new File(outDir, id + "-left.sql").toPath(), (afterLeft ? c[2] : c[1]).getBytes(Charset.forName("UTF-8")));
271            Files.write(new File(outDir, id + "-right.sql").toPath(), (afterLeft ? c[1] : c[2]).getBytes(Charset.forName("UTF-8")));
272            keyCsv.append(id).append(',').append(c[0].replace(',', ';')).append(',').append(afterLeft ? "left" : "right").append('\n');
273            written++;
274        }
275        Files.write(new File(outDir, "key.csv").toPath(), keyCsv.toString().getBytes(Charset.forName("UTF-8")));
276        return written;
277    }
278}