CandidateVerificationCli.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.mining.core.candidate;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

/** Post-processes staged candidate JSON after discovery. */
public final class CandidateVerificationCli {

	private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();

	private CandidateVerificationCli() {
	}

	public static void main(String[] args) {
		try {
			int exitCode = run(args);
			if (exitCode != 0) {
				System.exit(exitCode);
			}
		} catch (Exception e) {
			System.err.println("Candidate verification failed: " + e.getMessage()); //$NON-NLS-1$
			e.printStackTrace(System.err);
			System.exit(1);
		}
	}

	static int run(String[] args) throws IOException {
		Path candidateDir = Path.of("mining-candidates"); //$NON-NLS-1$
		Path reportDir = Path.of("docs/mining-report"); //$NON-NLS-1$
		Path curatedHintDir = null;
		String defaultSourceVersion = "21"; //$NON-NLS-1$

		for (int i = 0; i < args.length; i++) {
			switch (args[i]) {
			case "--candidate-dir": //$NON-NLS-1$
				candidateDir = Path.of(requireValue(args, ++i, "--candidate-dir")); //$NON-NLS-1$
				break;
			case "--report-dir": //$NON-NLS-1$
				reportDir = Path.of(requireValue(args, ++i, "--report-dir")); //$NON-NLS-1$
				break;
			case "--curated-hint-dir": //$NON-NLS-1$
				curatedHintDir = Path.of(requireValue(args, ++i, "--curated-hint-dir")); //$NON-NLS-1$
				break;
			case "--source-version": //$NON-NLS-1$
				defaultSourceVersion = requireValue(args, ++i, "--source-version"); //$NON-NLS-1$
				break;
			default:
				throw new IllegalArgumentException("Unknown option: " + args[i]); //$NON-NLS-1$
			}
		}

		Map<String, String> curatedRuleIndex = curatedHintDir == null
				? Map.of() : RuleFingerprintIndex.loadCurated(curatedHintDir);
		if (!curatedRuleIndex.isEmpty()) {
			System.out.println("Loaded " + curatedRuleIndex.size() //$NON-NLS-1$
					+ " curated rule fingerprints from " + curatedHintDir); //$NON-NLS-1$
		}

		CandidateStore store = new CandidateStore(candidateDir);
		List<MiningCandidate> candidates = new ArrayList<>(store.loadAll());
		migrateLegacyCandidates(candidates);
		candidates.sort(candidatePriority());
		CandidateVerifier verifier = new CandidateVerifier();
		Map<String, String> canonicalByRuleFingerprint = new HashMap<>();

		int verified = 0;
		int failed = 0;
		int duplicates = 0;
		for (MiningCandidate candidate : candidates) {
			if (!candidate.hasDeclaredSourceVersion()) {
				candidate.setSourceVersion(defaultSourceVersion);
			}
			if (isDiscarded(candidate.getStatus())) {
				store.save(candidate);
				continue;
			}

			if (candidate.getStatus() == CandidateStatus.PROMOTED
					|| candidate.getStatus() == CandidateStatus.APPROVED) {
				canonicalByRuleFingerprint.putIfAbsent(
						semanticFingerprint(candidate), candidate.getCandidateId());
				store.save(candidate);
				continue;
			}

			if (candidate.getStatus() == CandidateStatus.READY_FOR_REVIEW) {
				String fingerprint = semanticFingerprint(candidate);
				String curatedReference = curatedRuleIndex.get(fingerprint);
				if (curatedReference != null) {
					markDuplicate(candidate, "curated rule " + curatedReference); //$NON-NLS-1$
					duplicates++;
				} else {
					String canonicalId = canonicalByRuleFingerprint.putIfAbsent(
							fingerprint, candidate.getCandidateId());
					if (canonicalId != null && !canonicalId.equals(candidate.getCandidateId())) {
						markDuplicate(candidate, "staged candidate " + canonicalId); //$NON-NLS-1$
						duplicates++;
					}
				}
				store.save(candidate);
				continue;
			}

			CandidateVerification verification = verifier.verify(candidate);
			candidate.setVerification(verification);
			if (!verification.successful()) {
				failed++;
				System.out.println("Candidate " + candidate.getCandidateId() + " failed at " //$NON-NLS-1$ //$NON-NLS-2$
						+ verification.stage() + ": " + verification.message()); //$NON-NLS-1$
				store.save(candidate);
				continue;
			}

			String fingerprint = semanticFingerprint(candidate);
			String curatedReference = curatedRuleIndex.get(fingerprint);
			if (curatedReference != null) {
				markDuplicate(candidate, "curated rule " + curatedReference); //$NON-NLS-1$
				duplicates++;
			} else {
				String canonicalId = canonicalByRuleFingerprint.putIfAbsent(
						fingerprint, candidate.getCandidateId());
				if (canonicalId != null && !canonicalId.equals(candidate.getCandidateId())) {
					markDuplicate(candidate, "staged candidate " + canonicalId); //$NON-NLS-1$
					duplicates++;
				} else {
					advanceToReadyForReview(candidate);
					verified++;
					System.out.println("Verified candidate " + candidate.getCandidateId()); //$NON-NLS-1$
				}
			}
			store.save(candidate);
		}

		List<MiningCandidate> updated = store.loadAll().stream()
				.sorted(Comparator.comparing(MiningCandidate::getCandidateId))
				.toList();
		writeReport(updated, candidateDir, reportDir);
		long ready = updated.stream()
				.filter(candidate -> candidate.getStatus() == CandidateStatus.READY_FOR_REVIEW)
				.count();
		System.out.println("Candidate verification summary: " + verified + " verified, " //$NON-NLS-1$ //$NON-NLS-2$
				+ failed + " failed, " + duplicates + " duplicates, " //$NON-NLS-1$ //$NON-NLS-2$
				+ ready + " ready for review"); //$NON-NLS-1$
		return 0;
	}

	private static String semanticFingerprint(MiningCandidate candidate) throws IOException {
		return RuleFingerprintIndex.fingerprintDsl(candidate.getDslRule());
	}

	private static void markDuplicate(MiningCandidate candidate, String duplicateOf) {
		String message = "Normalized DSL duplicates " + duplicateOf; //$NON-NLS-1$
		candidate.setVerification(CandidateVerification.failure(
				CandidateVerification.Stage.DUPLICATE, message,
				CandidateVerifier.VERSION, 0, 0));
		candidate.transitionTo(CandidateStatus.DUPLICATE,
				"CandidateVerificationCli", message); //$NON-NLS-1$
	}

	/**
	 * Assigns deterministic ordinals before schema-v1 candidates are renamed.
	 * This prevents multiple proposals from one commit from collapsing onto the
	 * same schema-v2 filename during migration.
	 */
	private static void migrateLegacyCandidates(List<MiningCandidate> candidates) {
		Map<String, Integer> nextOrdinalByOrigin = new HashMap<>();
		for (MiningCandidate candidate : candidates) {
			if (candidate.getSchemaVersion() >= 2) {
				nextOrdinalByOrigin.merge(originKey(candidate),
						candidate.getCandidateOrdinal() + 1, Math::max);
			}
		}
		for (MiningCandidate candidate : candidates) {
			if (candidate.getSchemaVersion() >= 2) {
				continue;
			}
			String origin = originKey(candidate);
			int ordinal = nextOrdinalByOrigin.getOrDefault(origin, 0);
			nextOrdinalByOrigin.put(origin, ordinal + 1);
			candidate.setCandidateId(null);
			candidate.setCandidateOrdinal(ordinal);
			candidate.setRevision(candidate.getRevision());
			candidate.setSchemaVersion(2);
		}
	}

	private static String originKey(MiningCandidate candidate) {
		return String.join("\n", //$NON-NLS-1$
				nullToEmpty(candidate.getSourceRepo()),
				nullToEmpty(candidate.getSourceCommit()),
				nullToEmpty(candidate.getCategory()),
				nullToEmpty(candidate.getTargetHintFile()));
	}

	private static Comparator<MiningCandidate> candidatePriority() {
		return Comparator.comparingInt((MiningCandidate candidate) -> switch (candidate.getStatus()) {
		case PROMOTED -> 0;
		case APPROVED -> 1;
		case READY_FOR_REVIEW -> 2;
		case BEHAVIOR_VALID -> 3;
		case DSL_VALID -> 4;
		case DISCOVERED -> 5;
		case REJECTED, DUPLICATE, SUPERSEDED -> 9;
		}).thenComparing(candidate -> candidate.getDiscoveredAt() == null
				? "9999" : candidate.getDiscoveredAt()) //$NON-NLS-1$
				.thenComparing(MiningCandidate::getCandidateId);
	}

	private static void advanceToReadyForReview(MiningCandidate candidate) {
		if (candidate.getStatus() == CandidateStatus.DISCOVERED) {
			candidate.transitionTo(CandidateStatus.DSL_VALID,
					"CandidateVerifier", "DSL parser and validator passed"); //$NON-NLS-1$ //$NON-NLS-2$
		}
		if (candidate.getStatus() == CandidateStatus.DSL_VALID) {
			candidate.transitionTo(CandidateStatus.BEHAVIOR_VALID,
					"CandidateVerifier", "Positive and negative behavior examples passed"); //$NON-NLS-1$ //$NON-NLS-2$
		}
		if (candidate.getStatus() == CandidateStatus.BEHAVIOR_VALID) {
			candidate.transitionTo(CandidateStatus.READY_FOR_REVIEW,
					"CandidateVerificationCli", "All deterministic candidate gates passed"); //$NON-NLS-1$ //$NON-NLS-2$
		}
	}

	private static boolean isDiscarded(CandidateStatus status) {
		return status == CandidateStatus.REJECTED
				|| status == CandidateStatus.DUPLICATE
				|| status == CandidateStatus.SUPERSEDED;
	}

	private static void writeReport(List<MiningCandidate> candidates, Path candidateDir,
			Path reportDir) throws IOException {
		Files.createDirectories(reportDir);
		Files.writeString(reportDir.resolve("candidates.json"), //$NON-NLS-1$
				GSON.toJson(candidates) + System.lineSeparator(), StandardCharsets.UTF_8);

		Path reportCandidates = reportDir.resolve("candidates"); //$NON-NLS-1$
		Files.createDirectories(reportCandidates);
		for (MiningCandidate candidate : candidates) {
			Path source = candidateDir.resolve(candidate.toFileName());
			if (Files.isRegularFile(source)) {
				Files.copy(source, reportCandidates.resolve(candidate.toFileName()),
						StandardCopyOption.REPLACE_EXISTING);
			}
		}
		Files.writeString(reportDir.resolve("candidates.html"), buildHtml(candidates), //$NON-NLS-1$
				StandardCharsets.UTF_8);
	}

	private static String buildHtml(List<MiningCandidate> candidates) {
		StringBuilder html = new StringBuilder();
		html.append("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">") //$NON-NLS-1$
				.append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">") //$NON-NLS-1$
				.append("<title>Mining candidates</title>") //$NON-NLS-1$
				.append("<style>body{font-family:system-ui,sans-serif;max-width:1200px;margin:2rem auto;padding:0 1rem}") //$NON-NLS-1$
				.append("table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:.55rem;text-align:left;vertical-align:top}") //$NON-NLS-1$
				.append("th{background:#f4f4f4}code{overflow-wrap:anywhere}.ok{font-weight:600}.fail{font-weight:600}</style></head><body>") //$NON-NLS-1$
				.append("<h1>Staged mining candidates</h1>") //$NON-NLS-1$
				.append("<p>Only candidates marked <strong>READY_FOR_REVIEW</strong> passed deterministic DSL, duplicate, and behavior checks.</p>") //$NON-NLS-1$
				.append("<table><thead><tr><th>Status</th><th>Candidate</th><th>Proposal</th><th>Source</th><th>Verification</th></tr></thead><tbody>"); //$NON-NLS-1$
		for (MiningCandidate candidate : candidates) {
			CandidateVerification verification = candidate.getVerification();
			html.append("<tr><td>").append(escape(candidate.getStatus().name())).append("</td><td><a href=\"candidates/") //$NON-NLS-1$ //$NON-NLS-2$
					.append(escape(candidate.toFileName())).append("\"><code>") //$NON-NLS-1$
					.append(escape(candidate.getCandidateId())).append("</code></a><br>revision ") //$NON-NLS-1$
					.append(candidate.getRevision()).append("</td><td>") //$NON-NLS-1$
					.append(escape(candidate.getSummary())).append("<br><code>") //$NON-NLS-1$
					.append(escape(candidate.getTargetHintFile())).append("</code></td><td><a href=\"") //$NON-NLS-1$
					.append(escape(commitUrl(candidate))).append("\"><code>") //$NON-NLS-1$
					.append(escape(shortCommit(candidate.getSourceCommit()))).append("</code></a></td><td>"); //$NON-NLS-1$
			if (verification == null) {
				html.append("not run"); //$NON-NLS-1$
			} else {
				html.append("<span class=\"") //$NON-NLS-1$
						.append(verification.successful() ? "ok" : "fail") //$NON-NLS-1$ //$NON-NLS-2$
						.append("\">").append(escape(verification.stage().name())).append("</span><br>") //$NON-NLS-1$ //$NON-NLS-2$
						.append(escape(verification.message()));
			}
			html.append("</td></tr>"); //$NON-NLS-1$
		}
		return html.append("</tbody></table></body></html>\n").toString(); //$NON-NLS-1$
	}

	private static String commitUrl(MiningCandidate candidate) {
		String repo = candidate.getSourceRepo();
		if (repo == null) {
			return "#"; //$NON-NLS-1$
		}
		if (repo.endsWith(".git")) { //$NON-NLS-1$
			repo = repo.substring(0, repo.length() - 4);
		}
		return repo + "/commit/" + candidate.getSourceCommit(); //$NON-NLS-1$
	}

	private static String shortCommit(String commit) {
		return commit == null ? "unknown" : commit.substring(0, Math.min(12, commit.length())); //$NON-NLS-1$
	}

	private static String escape(String value) {
		if (value == null) {
			return ""; //$NON-NLS-1$
		}
		return value.replace("&", "&amp;") //$NON-NLS-1$ //$NON-NLS-2$
				.replace("<", "&lt;") //$NON-NLS-1$ //$NON-NLS-2$
				.replace(">", "&gt;") //$NON-NLS-1$ //$NON-NLS-2$
				.replace("\"", "&quot;"); //$NON-NLS-1$ //$NON-NLS-2$
	}

	private static String nullToEmpty(String value) {
		return value == null ? "" : value; //$NON-NLS-1$
	}

	private static String requireValue(String[] args, int index, String option) {
		if (index >= args.length) {
			throw new IllegalArgumentException(option + " requires a value"); //$NON-NLS-1$
		}
		return args[index];
	}
}