diff --git a/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java b/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java
index 25fa63e..d5c4928 100644
--- a/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java
+++ b/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java
@@ -3,6 +3,7 @@
import com.jobpilot.jobs.domain.ExtractedRequirements;
import com.jobpilot.jobs.domain.Job;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -15,6 +16,75 @@
public class DeterministicRequirementExtractor {
private static final Pattern YEARS = Pattern.compile(
"(?i)(?:minimum|min\\.?|at least|requires?|with)?\\s*(\\d+(?:\\.\\d+)?)\\s*\\+?\\s*(?:years?|yrs?)(?:\\s+of)?(?:\\s+(?:commercial|professional|industry))?\\s+experience");
+ /** Technical tokens REST is credibly listed beside; longer alternatives come first. */
+ private static final String REST_TECH_TOKEN = "(?:OOP|OO|MVVM|MVC|MVP|SOAP|JSON|XML|YAML"
+ + "|HTTPS|HTTP|gRPC|GraphQL|SOA|microservices|NoSQL|PostgreSQL|MySQL|MongoDB|SQL"
+ + "|Redis|Kafka|RabbitMQ|Spring Boot|Spring|Hibernate|JPA|JDBC|Maven|Gradle|JUnit"
+ + "|Mockito|Selenium|Cucumber|BDD|TDD|Docker|Kubernetes|CI/CD|Jenkins|Linux|AWS"
+ + "|Azure|GCP|HTML|CSS|React|Angular|Node|Django|Flask|\\.NET|APIs|API|JavaScript"
+ + "|TypeScript|Java|Kotlin|Scala|Python|C#|C\\+\\+|Git)";
+ /** Never a bare space: that is what keeps "Rest Assured" out of every list form. */
+ private static final String REST_LIST_DELIMITER = "(?:\\s*[,;/]\\s*|\\s+(?:and|or)\\s+)";
+ private static final String REST_LIST_END = "(?=\\s*[.)\\];:]|\\s*$)";
+ private static final String REST_INTRODUCER = "(?:experience\\s+(?:with|in)|knowledge\\s+of"
+ + "|understanding\\s+of|familiarity\\s+with|proficiency\\s+(?:in|with)"
+ + "|proficient\\s+(?:in|with)|skills?\\s+(?:in|with))";
+
+ /**
+ * Qualified REST. Bare "rest" is an ordinary English noun and "Rest Assured" is a test
+ * library, so neither is API evidence on its own, but production showed the token also
+ * carries genuine evidence as technical shorthand ("HTTP/REST", "REST/JSON", "APIs (REST)"),
+ * directly after a technical introducer, and as an item in a delimited technology list
+ * ("OO, MVC, REST", "Java, Spring Boot, REST, SQL").
+ *
+ *
The list forms require REST to be a complete item — a delimiter on both sides,
+ * or a delimiter and the end of the list — and the delimiter never matches a bare space. That
+ * is what keeps "Selenium, Cucumber, BDD, Rest Assured and CI/CD" negative: "Rest" there is
+ * followed by a word, not by a delimiter. A posting carrying both dirty and genuine text
+ * still matches, because the scan reaches the genuine occurrence.
+ */
+ private static final String QUALIFIED_REST = "(?:"
+ + "RESTful"
+ + "|REST[\\s-]?(?:API|service|endpoint|controller)s?"
+ + "|JAX-?RS"
+ + "|Spring\\s+REST"
+ + "|HTTP\\s*/\\s*REST"
+ + "|REST\\s*/\\s*JSON"
+ + "|APIs?\\s*\\(\\s*REST\\s*\\)"
+ + "|" + REST_INTRODUCER + "\\s+REST"
+ + "|" + REST_TECH_TOKEN + REST_LIST_DELIMITER + "REST"
+ + REST_LIST_DELIMITER + REST_TECH_TOKEN
+ + "|" + REST_TECH_TOKEN + REST_LIST_DELIMITER + REST_TECH_TOKEN
+ + REST_LIST_DELIMITER + "REST" + REST_LIST_END
+ + "|REST" + REST_LIST_DELIMITER + REST_TECH_TOKEN
+ + REST_LIST_DELIMITER + REST_TECH_TOKEN
+ + ")";
+
+ private static final String LANGUAGE_TOKEN = "(?:Java|Python|Rust|Kotlin|Scala|Ruby|PHP"
+ + "|C\\+\\+|C#|TypeScript|JavaScript|Elixir|Erlang|Swift|Haskell)";
+ private static final String LANGUAGE_DELIMITER = "(?:\\s*[,/]\\s*|\\s+(?:and|or)\\s+)";
+ private static final String TECHNICAL_INTRODUCER = "(?:programming\\s+languages?|languages?"
+ + "|technologies|tech\\s+stack|stack|experience\\s+(?:with|in)"
+ + "|proficient\\s+(?:with|in)|written\\s+in|skills\\s+(?:with|in))";
+ /** Go has to end as a list item; without this "experience with Java and go to market" matches. */
+ private static final String GO_LIST_TAIL =
+ "(?=\\s*[,;./)\\]]|\\s+(?:and|or)\\s+" + LANGUAGE_TOKEN + "|\\s*$)";
+
+ /**
+ * "Go" is an ordinary English verb, so a bare token is never evidence. Either it is spelled
+ * unambiguously, or it sits between two recognised languages in a delimited list, or
+ * it follows an explicit technical introducer and terminates as a list item. Simple
+ * two-item adjacency is deliberately rejected: "ready to go, Java developers should apply"
+ * would otherwise qualify. Conservative false negatives are preferred here — Go carries no
+ * scoring weight, while a false Go pollutes the evidence diagnostics depend on.
+ */
+ private static final String QUALIFIED_GO = "(?:Golang|goroutines?"
+ + "|Go[\\s-]?(?:developer|engineer|programming|language|module|routine)s?"
+ + "|" + LANGUAGE_TOKEN + LANGUAGE_DELIMITER + "Go" + LANGUAGE_DELIMITER + LANGUAGE_TOKEN
+ + "|" + TECHNICAL_INTRODUCER + "\\s*:?\\s*(?:" + LANGUAGE_TOKEN + LANGUAGE_DELIMITER
+ + "){0,3}Go" + GO_LIST_TAIL
+ + ")";
+
private static final Map TECHNOLOGIES = technologies();
private static final List PROGRAMMING = List.of("Java", "TypeScript", "JavaScript", "Python", "Kotlin", "C#", "C++", "Go");
// Whole words only: "internal" or "international" must not count as an internship.
@@ -96,17 +166,40 @@ public ExtractedRequirements extract(Job job) {
salary(text), job.getDeadline(), "DETERMINISTIC");
}
+ /**
+ * Insertion-ordered on purpose. {@link Map#copyOf} salts its iteration order per JVM run, so
+ * the extracted technology list — and therefore {@link ExtractedRequirements} record equality —
+ * used to differ between runs for identical text. Nothing depended on that while the rescore
+ * plan compared scores only; requirement-aware plan membership does.
+ */
private static Map technologies() {
Map map = new LinkedHashMap<>();
- for (String tech : List.of("Java", "Spring Boot", "Spring MVC", "Spring Security", "REST", "SQL",
- "PostgreSQL", "JPA", "Hibernate", "Maven", "JUnit", "Mockito", "React", "React Native",
- "TypeScript", "JavaScript", "HTML", "CSS", "Git", "GitHub Actions", "CI/CD", "Docker",
- "Kubernetes", "Python", "Kotlin", "C#", "C++", "Go", "AWS", "Azure", "GCP")) {
- String token = "CI/CD".equals(tech) ? "CI(?:/|\\s*)CD" : Pattern.quote(tech);
- String expression = "(?i)(? "CI(?:/|\\s*)CD";
+ case "REST" -> QUALIFIED_REST;
+ case "Go" -> QUALIFIED_GO;
+ case "Spring Boot" -> "Spring[\\s-]?Boot";
+ default -> Pattern.quote(tech);
+ };
}
private List extractTechnologies(String text) {
diff --git a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReport.java b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReport.java
index 9934e91..829a944 100644
--- a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReport.java
+++ b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReport.java
@@ -5,12 +5,13 @@
import com.jobpilot.matching.ScoreBand;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
/** Immutable, deterministic result; it contains no description, URL, or candidate data. */
public record ScoreRescorePreviewReport(
int inspectedJobs,
int exactMatches,
- int changedScoreCount,
+ int scoreDeltaChangedCount,
int changedBandCount,
int blockerRemovedCount,
int blockerAddedCount,
@@ -24,7 +25,84 @@ public record ScoreRescorePreviewReport(
QueueProjection matchesQueue,
QueueProjection reviewQueue,
List changedJobs,
- JobPreview juniorJavaDeveloper) {
+ JobPreview juniorJavaDeveloper,
+ ChangeCounts changeCounts,
+ List requirementsChangedJobs) {
+
+ /**
+ * Plan membership, decomposed. {@code scoreDeltaChangedCount} above counts a numeric score
+ * move; {@code scoreChangedCount} here counts a {@code ScoreCard} identity change, which is
+ * what actually decides whether a row is written.
+ */
+ public record ChangeCounts(
+ int scoreChangedCount,
+ int requirementsChangedCount,
+ int requirementsOnlyChangedCount,
+ int changedPlanCount,
+ int unchangedCount) {
+ }
+
+ /** Every persisted component of {@code ExtractedRequirements}, in record order. */
+ public enum RequirementField {
+ SENIORITY,
+ INTERNSHIP_OR_TRAINEE,
+ REQUIRED_EXPERIENCE_YEARS,
+ REQUIRED_EDUCATION,
+ FINAL_YEAR_MANDATORY,
+ TECHNOLOGIES,
+ PROGRAMMING_LANGUAGES,
+ SPOKEN_LANGUAGES,
+ LOCATION,
+ REMOTE_ELIGIBILITY,
+ MENTORSHIP_SIGNALS,
+ WORK_AUTHORIZATION,
+ SALARY,
+ APPLICATION_DEADLINE,
+ EXTRACTION_METHOD
+ }
+
+ /** One differing persisted field. Both values arrive already sanitized and bounded. */
+ public record RequirementFieldChange(
+ RequirementField field,
+ String storedValue,
+ String computedValue) {
+
+ public static final int MAX_VALUE_LENGTH = 80;
+
+ public RequirementFieldChange {
+ Objects.requireNonNull(field, "Requirement field is required");
+ requireBounded(storedValue, "Stored");
+ requireBounded(computedValue, "Computed");
+ }
+
+ private static void requireBounded(String value, String side) {
+ if (value == null || value.length() > MAX_VALUE_LENGTH) {
+ throw new IllegalArgumentException(
+ side + " requirement value must be sanitized and bounded");
+ }
+ }
+ }
+
+ /**
+ * Why one row entered the plan. Carries no description, URL, or candidate data, and cannot be
+ * constructed without naming at least one field, so a planned row can never be invisible.
+ */
+ public record RequirementsPreview(
+ long jobId,
+ String title,
+ boolean scoreChanged,
+ int storedScore,
+ int computedScore,
+ List changedFields) {
+
+ public RequirementsPreview {
+ changedFields = List.copyOf(changedFields);
+ if (changedFields.isEmpty()) {
+ throw new IllegalArgumentException(
+ "A requirements preview must name at least one changed field");
+ }
+ }
+ }
public record BoundaryCrossings(
List unsuitable,
diff --git a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReportRenderer.java b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReportRenderer.java
index 48cf160..74ffd1c 100644
--- a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReportRenderer.java
+++ b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewReportRenderer.java
@@ -4,6 +4,8 @@
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.JobPreview;
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.QueueEntry;
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.QueueProjection;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementFieldChange;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementsPreview;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
@@ -24,7 +26,7 @@ public List render(ScoreRescorePreviewResult result) {
List lines = new ArrayList<>();
lines.add("SCORE_RESCORE_PREVIEW status=SUCCESS inspected=" + report.inspectedJobs()
+ " exact=" + report.exactMatches()
- + " changedScores=" + report.changedScoreCount()
+ + " scoreDeltaChanged=" + report.scoreDeltaChangedCount()
+ " changedBands=" + report.changedBandCount()
+ " blockerRemoved=" + report.blockerRemovedCount()
+ " blockerAdded=" + report.blockerAddedCount()
@@ -32,6 +34,13 @@ public List render(ScoreRescorePreviewResult result) {
+ " decreases=" + report.scoreDecreaseCount()
+ " zeroToPositive=" + report.storedZeroToPositiveCount()
+ " positiveToZero=" + report.storedPositiveToZeroCount());
+ lines.add("SCORE_RESCORE_PREVIEW_PLAN scoreChanged="
+ + report.changeCounts().scoreChangedCount()
+ + " requirementsChanged=" + report.changeCounts().requirementsChangedCount()
+ + " requirementsOnlyChanged="
+ + report.changeCounts().requirementsOnlyChangedCount()
+ + " changedPlan=" + report.changeCounts().changedPlanCount()
+ + " unchanged=" + report.changeCounts().unchangedCount());
lines.add(bound("SCORE_RESCORE_PREVIEW_DELTA distribution="
+ report.scoreDeltaDistribution()));
lines.add(bound("SCORE_RESCORE_PREVIEW_BOUNDARIES unsuitable="
@@ -43,6 +52,9 @@ public List render(ScoreRescorePreviewResult result) {
for (JobPreview job : report.changedJobs()) {
lines.add(renderJob("SCORE_RESCORE_PREVIEW_CHANGED", job));
}
+ for (RequirementsPreview requirements : report.requirementsChangedJobs()) {
+ lines.add(renderRequirements(requirements));
+ }
renderQueue(lines, "matches", report.matchesQueue());
renderQueue(lines, "review", report.reviewQueue());
if (report.juniorJavaDeveloper() == null) {
@@ -81,6 +93,23 @@ private String renderJob(String prefix, JobPreview job) {
+ "->" + position(job.newQueuePosition()));
}
+ /** Names every persisted field the write would change, so no planned row is opaque. */
+ private String renderRequirements(RequirementsPreview requirements) {
+ StringBuilder line = new StringBuilder("SCORE_RESCORE_PREVIEW_REQUIREMENTS jobId=")
+ .append(requirements.jobId())
+ .append(" title=").append(quote(requirements.title()))
+ .append(" scoreChanged=").append(requirements.scoreChanged())
+ .append(" score=").append(requirements.storedScore())
+ .append("->").append(requirements.computedScore())
+ .append(" changedFields=").append(requirements.changedFields().size());
+ for (RequirementFieldChange change : requirements.changedFields()) {
+ line.append(' ').append(change.field()).append('=')
+ .append(quote(change.storedValue()))
+ .append("->").append(quote(change.computedValue()));
+ }
+ return bound(line.toString());
+ }
+
private void renderQueue(List lines, String name, QueueProjection queue) {
lines.add("SCORE_RESCORE_PREVIEW_QUEUE queue=/" + name
+ " phase=BEFORE total=" + queue.before().size());
diff --git a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java
index 3ca2652..a4cddde 100644
--- a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java
+++ b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java
@@ -18,13 +18,19 @@
import com.jobpilot.matching.rescore.ScoreRescorePlan;
import com.jobpilot.matching.rescore.ScoreRescorePlanEntry;
import com.jobpilot.matching.rescore.ScoreRescorePlanResult;
+import com.jobpilot.common.Utf16;
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.BoundaryCrossings;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.ChangeCounts;
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.JobPreview;
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.QueueEntry;
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.QueueProjection;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementField;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementFieldChange;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementsPreview;
import com.jobpilot.matching.preview.ScoreRescorePreviewReport.ScoreSnapshot;
import com.jobpilot.matching.preview.ScoreRescorePreviewResult.ErrorCategory;
import java.time.Instant;
+import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -48,6 +54,10 @@
@Service
public class ScoreRescorePreviewService {
static final String TARGET_TITLE = "Code First Girls Programme - Junior Java Developer";
+ private static final int MAX_LIST_ELEMENTS = 12;
+ private static final int MAX_LIST_ELEMENT_LENGTH = 24;
+ /** What {@link PreviewTextSanitizer} returns once a value normalises to nothing. */
+ private static final String BLANK_SANITIZER_MARKER = "";
private final JobScoreRepository scores;
private final JobRequirementRepository requirements;
@@ -225,11 +235,31 @@ private ScoreRescorePreviewReport buildReport(List jobs) {
List possibleCrossings = new ArrayList<>();
List strongCrossings = new ArrayList<>();
List changes = new ArrayList<>();
+ List requirementChanges = new ArrayList<>();
+ int scoreChanged = 0;
+ int requirementsChanged = 0;
+ int requirementsOnlyChanged = 0;
+ int unchanged = 0;
JobPreview named = null;
for (EvaluatedJob evaluated : jobs) {
ScoreCard oldScore = evaluated.storedScore();
ScoreCard newScore = evaluated.computed().score();
+ ExtractedRequirements oldRequirements = evaluated.storedRequirements();
+ ExtractedRequirements newRequirements = evaluated.computed().requirements();
+ // Identical to the equality that decides plan membership, so the two can never diverge.
+ boolean scoreDiffers = !oldScore.equals(newScore);
+ boolean requirementsDiffer = !oldRequirements.equals(newRequirements);
+ if (scoreDiffers) scoreChanged++;
+ if (requirementsDiffer) requirementsChanged++;
+ if (requirementsDiffer && !scoreDiffers) requirementsOnlyChanged++;
+ if (!scoreDiffers && !requirementsDiffer) unchanged++;
+ if (requirementsDiffer) {
+ requirementChanges.add(new RequirementsPreview(evaluated.job().getId(),
+ PreviewTextSanitizer.text(evaluated.job().getTitle(), 120), scoreDiffers,
+ oldScore.score(), newScore.score(),
+ requirementDiff(oldRequirements, newRequirements)));
+ }
int delta = newScore.score() - oldScore.score();
deltas.merge(delta, 1, Integer::sum);
if (oldScore.equals(newScore)) exact++;
@@ -263,7 +293,96 @@ private ScoreRescorePreviewReport buildReport(List jobs) {
Collections.unmodifiableMap(orderedDeltas), zeroToPositive, positiveToZero,
new BoundaryCrossings(List.copyOf(unsuitableCrossings),
List.copyOf(possibleCrossings), List.copyOf(strongCrossings)),
- staleMatchInversions(jobs), matches, review, List.copyOf(changes), named);
+ staleMatchInversions(jobs), matches, review, List.copyOf(changes), named,
+ new ChangeCounts(scoreChanged, requirementsChanged, requirementsOnlyChanged,
+ scoreChanged + requirementsOnlyChanged, unchanged),
+ List.copyOf(requirementChanges));
+ }
+
+ /**
+ * Every persisted requirement field that differs, and only those.
+ *
+ * Raw values are compared first and converted to display text only after a difference is
+ * established: comparing rendered text would hide two long values that share a truncated
+ * prefix. Equality mirrors {@code ExtractedRequirements} record equality exactly — including
+ * order-sensitive list comparison — because that is what decides plan membership, so a planned
+ * row can never produce an empty diff.
+ */
+ private List requirementDiff(ExtractedRequirements stored,
+ ExtractedRequirements computed) {
+ List changes = new ArrayList<>();
+ for (RequirementField field : RequirementField.values()) {
+ Object storedValue = raw(stored, field);
+ Object computedValue = raw(computed, field);
+ if (Objects.equals(storedValue, computedValue)) continue;
+ changes.add(new RequirementFieldChange(field,
+ display(storedValue), display(computedValue)));
+ }
+ return List.copyOf(changes);
+ }
+
+ /** Exhaustive by construction: a new record component cannot compile without a branch here. */
+ private Object raw(ExtractedRequirements value, RequirementField field) {
+ return switch (field) {
+ case SENIORITY -> value.seniority();
+ case INTERNSHIP_OR_TRAINEE -> value.internshipOrTrainee();
+ case REQUIRED_EXPERIENCE_YEARS -> value.requiredExperienceYears();
+ case REQUIRED_EDUCATION -> value.requiredEducation();
+ case FINAL_YEAR_MANDATORY -> value.finalYearMandatory();
+ case TECHNOLOGIES -> value.technologies();
+ case PROGRAMMING_LANGUAGES -> value.programmingLanguages();
+ case SPOKEN_LANGUAGES -> value.spokenLanguages();
+ case LOCATION -> value.location();
+ case REMOTE_ELIGIBILITY -> value.remoteEligibility();
+ case MENTORSHIP_SIGNALS -> value.mentorshipSignals();
+ case WORK_AUTHORIZATION -> value.workAuthorization();
+ case SALARY -> value.salary();
+ case APPLICATION_DEADLINE -> value.applicationDeadline();
+ case EXTRACTION_METHOD -> value.extractionMethod();
+ };
+ }
+
+ private String display(Object value) {
+ if (value == null) return "(null)";
+ if (value instanceof List> list) return displayList(list);
+ if (value instanceof Instant instant) {
+ return DateTimeFormatter.ISO_INSTANT.format(instant);
+ }
+ if (value instanceof String text) return boundedText(text);
+ return bounded(String.valueOf(value));
+ }
+
+ /**
+ * Normalise first, then decide emptiness, so "", " " and control-only text all read the
+ * same while staying distinct from a genuinely absent "(null)". Raw equality has already been
+ * decided by the caller, so two different raw values that both normalise to nothing are still
+ * reported as a change.
+ */
+ private String boundedText(String value) {
+ String safe = bounded(value);
+ return safe.isEmpty() || BLANK_SANITIZER_MARKER.equals(safe) ? "(empty)" : safe;
+ }
+
+ private String displayList(List> values) {
+ if (values.isEmpty()) return "[]";
+ StringBuilder rendered = new StringBuilder("[");
+ for (int index = 0; index < Math.min(values.size(), MAX_LIST_ELEMENTS); index++) {
+ if (index > 0) rendered.append(", ");
+ rendered.append(PreviewTextSanitizer.text(
+ String.valueOf(values.get(index)), MAX_LIST_ELEMENT_LENGTH));
+ }
+ if (values.size() > MAX_LIST_ELEMENTS) {
+ rendered.append(", +").append(values.size() - MAX_LIST_ELEMENTS).append(" more");
+ }
+ return bounded(rendered.append(']').toString());
+ }
+
+ /** Visibly truncated, so an operator can tell a cut value from a short one. */
+ private String bounded(String value) {
+ String safe = PreviewTextSanitizer.text(value, RequirementFieldChange.MAX_VALUE_LENGTH + 1);
+ return safe.length() > RequirementFieldChange.MAX_VALUE_LENGTH
+ ? Utf16.truncate(safe, RequirementFieldChange.MAX_VALUE_LENGTH - 1) + "…"
+ : safe;
}
private JobPreview preview(EvaluatedJob evaluated, Map oldPositions,
@@ -468,7 +587,8 @@ private ScoreRescorePreviewReport emptyReport() {
QueueProjection emptyQueue = new QueueProjection(List.of(), List.of());
return new ScoreRescorePreviewReport(0, 0, 0, 0, 0, 0, 0, 0, Map.of(),
0, 0, new BoundaryCrossings(List.of(), List.of(), List.of()), List.of(),
- emptyQueue, emptyQueue, List.of(), null);
+ emptyQueue, emptyQueue, List.of(), null,
+ new ChangeCounts(0, 0, 0, 0, 0), List.of());
}
private PreviewAbort abort(ErrorCategory category, String safeMessage) {
diff --git a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlan.java b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlan.java
index 9ffb26b..1d13733 100644
--- a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlan.java
+++ b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlan.java
@@ -20,6 +20,13 @@ public ScoreRescorePlan(ScoreRescorePreviewReport report,
public ScoreRescorePlan {
Objects.requireNonNull(report, "Preview report is required");
entries = normalized(entries);
+ // The write may only touch rows the preview accounted for, so the two are tied here
+ // rather than left to agree by convention.
+ if (report.changeCounts() == null
+ || report.changeCounts().changedPlanCount() != entries.size()) {
+ throw new IllegalArgumentException(
+ "Plan entry count does not match the preview report changed plan count");
+ }
if (!ScoreRescorePlanFingerprint.fingerprint(entries).equals(fingerprint)) {
throw new IllegalArgumentException("Plan fingerprint does not match its entries");
}
diff --git a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java
index 9330151..4ab6fb2 100644
--- a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java
+++ b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java
@@ -63,10 +63,25 @@ public static ScoreRescorePlanEntry snapshot(JobScore scoreRow,
computed.score(), computed.requirements());
}
+ /**
+ * A row is planned when either persisted projection would move. The write transaction already
+ * re-persists requirements whenever they differ, so a score-only test let it change rows the
+ * preview never showed, while requirement drift carrying no score change — a stale seniority,
+ * a withdrawn dirty technology token — stayed permanently unreachable.
+ */
public boolean changed() {
+ return !storedScore.equals(computedScore)
+ || !storedRequirements.equals(computedRequirements);
+ }
+
+ public boolean scoreChanged() {
return !storedScore.equals(computedScore);
}
+ public boolean requirementsChanged() {
+ return !storedRequirements.equals(computedRequirements);
+ }
+
private static long requireId(Long id, String type) {
if (id == null || id < 1) {
throw new IllegalArgumentException("Persisted " + type + " identity is required");
diff --git a/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java b/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java
index 3627fa3..55b877c 100644
--- a/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java
+++ b/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java
@@ -11,7 +11,10 @@
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
+import java.util.List;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
class DeterministicRequirementExtractorTest {
private final Clock clock = Clock.fixed(Instant.parse("2026-07-17T12:00:00Z"), ZoneOffset.UTC);
@@ -280,6 +283,241 @@ void productionRegressionFixturesForJobs86And98() {
.isEqualTo("UNKNOWN");
}
+ // ---------------------------------------------------------------- extractor hygiene
+
+ private List technologiesOf(String description) {
+ return extractor.extract(jobWithDescription("Software Engineer", description))
+ .technologies();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "We build REST API services",
+ "Design REST APIs for partners",
+ "Experience with REST service design",
+ "Owns several REST services",
+ "Implements a REST endpoint",
+ "Documented REST endpoints",
+ "Writes a REST controller",
+ "Several REST controllers in Spring",
+ "Strong RESTful experience",
+ "Builds RESTful API layers",
+ "Uses JAX-RS in production",
+ "Spring REST is used throughout"
+ })
+ void extractsQualifiedRestEvidence(String description) {
+ assertThat(technologiesOf(description)).contains("REST");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "HTTP/REST is used across services",
+ "Networking protocols (TCP/IP, DHCP, HTTP / REST)",
+ "Experience with REST/JSON and Git",
+ "Payloads use REST / JSON everywhere",
+ "Integrating public APIs (REST)",
+ "Exposes an API (REST) for partners",
+ "Exposes an API ( REST ) for partners"
+ })
+ void extractsTechnicalRestShorthand(String description) {
+ assertThat(technologiesOf(description)).contains("REST");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "Experience with REST is required",
+ "Experience in REST is required",
+ "Knowledge of REST is required",
+ "Understanding of REST is required",
+ "Familiarity with REST is required",
+ "Proficiency in REST is required",
+ "Skills with REST are required"
+ })
+ void extractsRestAfterATechnicalIntroducer(String description) {
+ assertThat(technologiesOf(description)).contains("REST");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "Experience and understanding of OO, MVC, REST.",
+ "Solid grasp of OOP, MVC, REST",
+ "Java, Spring Boot, REST, SQL, PostgreSQL",
+ "technologies: Java, REST, SQL",
+ "stack: Java, REST, SQL"
+ })
+ void extractsRestAsATechnologyListItem(String description) {
+ assertThat(technologiesOf(description)).contains("REST");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "Integration with the rest of the stack",
+ "ready for integration with the rest of the stack",
+ "Automation with Rest Assured",
+ "Automation with RestAssured",
+ "learn tools such as Selenium, Cucumber, BDD, Rest Assured and CI/CD",
+ "Consumes the interest API daily",
+ "Calls the forest API nightly",
+ "Integrates a restaurant API",
+ "leave the rest for later",
+ "introduce you to the rest of the team",
+ "rest of your responsibilities are listed below",
+ "experience with the rest of the stack"
+ })
+ void rejectsUnqualifiedRestEvidence(String description) {
+ assertThat(technologiesOf(description)).doesNotContain("REST");
+ }
+
+ @Test
+ void extractsRestWhenDirtyAndGenuineEvidenceBothOccur() {
+ assertThat(technologiesOf("Automation with Rest Assured over the rest of the stack, "
+ + "plus a genuine REST API layer")).contains("REST");
+ assertThat(technologiesOf("Rest Assured for testing; services expose REST APIs"))
+ .contains("REST");
+ }
+
+ /** Verbatim contexts from the nine production rows the audit surfaced. */
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "Experience and understanding of OO, MVC, REST.",
+ "Networking protocols and technologies (TCP/IP, DHCP, HTTP/REST)",
+ "Experience with REST/JSON and Git",
+ "Experience in exposing and integrating public APIs (REST)",
+ "Java, Spring Boot, REST, SQL, PostgreSQL"
+ })
+ void keepsGenuineProductionRestEvidence(String description) {
+ assertThat(technologiesOf(description)).contains("REST");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "learn tools such as Selenium, Cucumber, BDD, Rest Assured and CI/CD",
+ "ready for integration with the rest of the stack"
+ })
+ void dropsDirtyProductionRestEvidence(String description) {
+ assertThat(technologiesOf(description)).doesNotContain("REST");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "Golang experience preferred",
+ "Understands goroutine scheduling",
+ "Tuning goroutines at scale",
+ "Hiring a Go developer",
+ "Hiring a Go engineer",
+ "Go programming is a plus",
+ "The Go language is used here",
+ "Uses Go modules",
+ "Knows Go routines",
+ "Python, Go, Java",
+ "languages: Go, Rust",
+ "programming languages: Go, Rust",
+ "technologies: Go, Rust",
+ "tech stack: Go, Rust",
+ "stack: Go, Rust",
+ "experience with Go and Rust",
+ "experience in Go and Rust",
+ "proficient with Go, Rust",
+ "proficient in Go, Rust",
+ "written in Go, mostly",
+ "skills with Go and Rust",
+ "skills in Go and Rust"
+ })
+ void extractsGoOnlyWithTechnicalContext(String description) {
+ assertThat(technologiesOf(description)).contains("Go");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "ready to go, Java developers should apply",
+ "go and Java developers will review it",
+ "Python developers go and Java developers follow",
+ "ready to go / Java experience preferred",
+ "go, Java is installed separately",
+ "experience with Java and go to market",
+ "please go ahead and apply",
+ "willing to go the extra mile",
+ "supports go-live weekends",
+ "experience with go-to-market strategy"
+ })
+ void rejectsOrdinaryEnglishGo(String description) {
+ assertThat(technologiesOf(description)).doesNotContain("Go");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "Experience with Spring Boot 3",
+ "Experience with SpringBoot microservices",
+ "We use Spring-Boot in production"
+ })
+ void canonicalisesSpringBootAliases(String description) {
+ assertThat(technologiesOf(description)).contains("Spring Boot");
+ }
+
+ @Test
+ void extractsJvmAsItsOwnCanonicalTerm() {
+ assertThat(technologiesOf("Services run on the JVM"))
+ .contains("JVM").doesNotContain("Java");
+ }
+
+ @Test
+ void jvmIsNotAProgrammingLanguage() {
+ var requirements = extractor.extract(
+ jobWithDescription("Software Engineer", "Runs on the JVM, written in Go, mostly"));
+
+ assertThat(requirements.technologies()).contains("JVM", "Go");
+ assertThat(requirements.programmingLanguages()).contains("Go").doesNotContain("JVM");
+ }
+
+ /** Deliberate deferral: the Postgres alias waits for the scoring redesign, not this patch. */
+ @Test
+ void extractsExactPostgresqlButNotThePostgresAlias() {
+ assertThat(technologiesOf("We run PostgreSQL 16 here")).contains("PostgreSQL");
+ assertThat(technologiesOf("Data stored in Postgres")).doesNotContain("PostgreSQL");
+ }
+
+ @Test
+ void keepsExistingWordBoundaryBehaviour() {
+ assertThat(technologiesOf("Strong JavaScript skills only"))
+ .contains("JavaScript").doesNotContain("Java");
+ assertThat(technologiesOf("Core Java expertise")).contains("Java");
+ assertThat(technologiesOf("Pipelines in GitHub Actions"))
+ .contains("GitHub Actions").doesNotContain("Git");
+ assertThat(technologiesOf("Version control with Git")).contains("Git");
+ assertThat(technologiesOf("MySQL and NoSQL stores")).doesNotContain("SQL");
+ assertThat(technologiesOf("Strong SQL skills")).contains("SQL");
+ assertThat(technologiesOf("Pipelines use CI/CD")).contains("CI/CD");
+ assertThat(technologiesOf("Pipelines use CI CD")).contains("CI/CD");
+ assertThat(technologiesOf("Pipelines use CICD")).contains("CI/CD");
+ assertThat(technologiesOf("Backends in C# and C++")).contains("C#", "C++");
+ }
+
+ /** The terms this patch does not touch must keep matching exactly as before. */
+ @Test
+ void keepsEveryUntouchedTechnologyWorking() {
+ assertThat(technologiesOf("Java, Spring MVC, Spring Security, SQL, PostgreSQL, JPA, "
+ + "Hibernate, Maven, JUnit, Mockito, React, React Native, TypeScript, "
+ + "JavaScript, HTML, CSS, Git, GitHub Actions, CI/CD, Docker, Kubernetes, "
+ + "Python, Kotlin, C#, C++, AWS, Azure and GCP"))
+ .contains("Java", "Spring MVC", "Spring Security", "SQL", "PostgreSQL", "JPA",
+ "Hibernate", "Maven", "JUnit", "Mockito", "React", "React Native",
+ "TypeScript", "JavaScript", "HTML", "CSS", "Git", "GitHub Actions",
+ "CI/CD", "Docker", "Kubernetes", "Python", "Kotlin", "C#", "C++",
+ "AWS", "Azure", "GCP");
+ }
+
+ /**
+ * Record equality is order-sensitive and now decides rescore plan membership, so extraction
+ * order must be stable rather than salted per JVM run.
+ */
+ @Test
+ void extractsTechnologiesInAStableDeclarationOrder() {
+ String description = "Kubernetes, Docker, React, PostgreSQL and Java";
+
+ assertThat(technologiesOf(description))
+ .containsExactly("Java", "PostgreSQL", "React", "Docker", "Kubernetes");
+ }
+
private Job jobWithDescription(String title, String description) {
return normalizer.normalize(new RawJob("fixture", "text-1", "https://example.com/jobs/text-1",
title, "Example", "Bucharest, Romania", description, null,
diff --git a/src/test/java/com/jobpilot/matching/preview/RequirementDiffTest.java b/src/test/java/com/jobpilot/matching/preview/RequirementDiffTest.java
new file mode 100644
index 0000000..8d6b28e
--- /dev/null
+++ b/src/test/java/com/jobpilot/matching/preview/RequirementDiffTest.java
@@ -0,0 +1,244 @@
+package com.jobpilot.matching.preview;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.jobpilot.jobs.domain.ExtractedRequirements;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementField;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementFieldChange;
+import java.lang.reflect.Method;
+import java.time.Instant;
+import java.util.List;
+import java.util.Locale;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The requirement diff is what makes a planned row explainable before a guarded write, so it is
+ * exercised directly rather than only through the preview service.
+ */
+class RequirementDiffTest {
+ private final ScoreRescorePreviewService service =
+ new ScoreRescorePreviewService(null, null, null, null);
+
+ private List diff(ExtractedRequirements stored,
+ ExtractedRequirements computed) {
+ return ReflectionTestSupport.requirementDiff(service, stored, computed);
+ }
+
+ @Test
+ void reportsTechnologiesOnly() {
+ var stored = base();
+ var computed = withTechnologies(base(), List.of("Java", "REST"));
+
+ assertThat(diff(stored, computed)).singleElement().satisfies(change -> {
+ assertThat(change.field()).isEqualTo(RequirementField.TECHNOLOGIES);
+ assertThat(change.storedValue()).isEqualTo("[Java]");
+ assertThat(change.computedValue()).isEqualTo("[Java, REST]");
+ });
+ }
+
+ @Test
+ void reportsProgrammingLanguagesOnly() {
+ var computed = new ExtractedRequirements("JUNIOR", false, null, null, false,
+ List.of("Java"), List.of("Java", "Go"), List.of(), "Bucharest",
+ "Romania eligible", List.of(), null, null, null, "DETERMINISTIC");
+
+ assertThat(diff(base(), computed)).singleElement()
+ .satisfies(change -> assertThat(change.field())
+ .isEqualTo(RequirementField.PROGRAMMING_LANGUAGES));
+ }
+
+ /** The documented pre-existing drift: stored JUNIOR, freshly UNKNOWN. */
+ @Test
+ void reportsSeniorityOnly() {
+ var computed = withSeniority(base(), "UNKNOWN");
+
+ assertThat(diff(base(), computed)).singleElement().satisfies(change -> {
+ assertThat(change.field()).isEqualTo(RequirementField.SENIORITY);
+ assertThat(change.storedValue()).isEqualTo("JUNIOR");
+ assertThat(change.computedValue()).isEqualTo("UNKNOWN");
+ });
+ }
+
+ @Test
+ void reportsEveryDifferingFieldInRecordOrder() {
+ var computed = new ExtractedRequirements("UNKNOWN", true, 2.0, "BSc", true,
+ List.of("Java", "Go"), List.of("Java", "Go"), List.of("English (B2)"), "Cluj",
+ "Remote from Romania allowed", List.of("mentor"), "visa", "1000 EUR",
+ Instant.parse("2026-09-01T00:00:00Z"), "DETERMINISTIC");
+
+ assertThat(diff(base(), computed)).extracting(RequirementFieldChange::field)
+ .containsExactly(RequirementField.SENIORITY,
+ RequirementField.INTERNSHIP_OR_TRAINEE,
+ RequirementField.REQUIRED_EXPERIENCE_YEARS,
+ RequirementField.REQUIRED_EDUCATION,
+ RequirementField.FINAL_YEAR_MANDATORY,
+ RequirementField.TECHNOLOGIES,
+ RequirementField.PROGRAMMING_LANGUAGES,
+ RequirementField.SPOKEN_LANGUAGES,
+ RequirementField.LOCATION,
+ RequirementField.REMOTE_ELIGIBILITY,
+ RequirementField.MENTORSHIP_SIGNALS,
+ RequirementField.WORK_AUTHORIZATION,
+ RequirementField.SALARY,
+ RequirementField.APPLICATION_DEADLINE);
+ }
+
+ @Test
+ void reportsNothingWhenRequirementsAreIdentical() {
+ assertThat(diff(base(), base())).isEmpty();
+ }
+
+ /** Record equality is order-sensitive, so a reorder is a real change and must be shown. */
+ @Test
+ void reportsListReorderAsAChange() {
+ var computed = withTechnologies(base(), List.of("REST", "Java"));
+ var stored = withTechnologies(base(), List.of("Java", "REST"));
+
+ assertThat(diff(stored, computed)).singleElement()
+ .satisfies(change -> assertThat(change.field())
+ .isEqualTo(RequirementField.TECHNOLOGIES));
+ }
+
+ /** Comparison happens before truncation, so a late difference cannot be hidden. */
+ @Test
+ void reportsValuesDifferingOnlyBeyondTheDisplayLimit() {
+ String prefix = "x".repeat(RequirementFieldChange.MAX_VALUE_LENGTH + 20);
+ var stored = withEducation(base(), prefix + "AAA");
+ var computed = withEducation(base(), prefix + "BBB");
+
+ assertThat(diff(stored, computed)).singleElement().satisfies(change -> {
+ assertThat(change.field()).isEqualTo(RequirementField.REQUIRED_EDUCATION);
+ assertThat(change.storedValue()).hasSizeLessThanOrEqualTo(
+ RequirementFieldChange.MAX_VALUE_LENGTH);
+ assertThat(change.storedValue()).endsWith("…");
+ });
+ }
+
+ @Test
+ void distinguishesNullFromEmpty() {
+ var stored = withEducation(base(), null);
+ var computed = withEducation(base(), "");
+
+ assertThat(diff(stored, computed)).singleElement().satisfies(change -> {
+ assertThat(change.storedValue()).isEqualTo("(null)");
+ assertThat(change.computedValue()).isEqualTo("(empty)");
+ });
+ }
+
+ @Test
+ void rendersWhitespaceOnlyValuesAsEmpty() {
+ assertThat(diff(base(), withEducation(base(), " "))).singleElement()
+ .satisfies(change -> {
+ assertThat(change.storedValue()).isEqualTo("(null)");
+ assertThat(change.computedValue()).isEqualTo("(empty)");
+ });
+ assertThat(diff(base(), withEducation(base(), "\n\t "))).singleElement()
+ .satisfies(change -> assertThat(change.computedValue()).isEqualTo("(empty)"));
+ }
+
+ @Test
+ void collapsesAndStripsWhitespaceAroundRealText() {
+ assertThat(diff(base(), withEducation(base(), " BSc Computer\n Science ")))
+ .singleElement()
+ .satisfies(change -> assertThat(change.computedValue())
+ .isEqualTo("BSc Computer Science"));
+ }
+
+ /** Raw equality decides the change; rendering only explains it afterwards. */
+ @Test
+ void detectsAChangeBetweenTwoValuesThatBothRenderAsEmpty() {
+ var stored = withEducation(base(), "");
+ var computed = withEducation(base(), " ");
+
+ assertThat(diff(stored, computed)).singleElement().satisfies(change -> {
+ assertThat(change.field()).isEqualTo(RequirementField.REQUIRED_EDUCATION);
+ assertThat(change.storedValue()).isEqualTo("(empty)");
+ assertThat(change.computedValue()).isEqualTo("(empty)");
+ });
+ }
+
+ @Test
+ void boundsLongListsWithAVisibleRemainder() {
+ List many = List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l",
+ "m", "n");
+
+ assertThat(diff(base(), withTechnologies(base(), many))).singleElement()
+ .satisfies(change -> {
+ assertThat(change.computedValue()).contains("+2 more");
+ assertThat(change.computedValue()).hasSizeLessThanOrEqualTo(
+ RequirementFieldChange.MAX_VALUE_LENGTH);
+ });
+ }
+
+ /**
+ * A new persisted component must not be able to slip past the diff unnoticed, so the enum is
+ * pinned to the record's own components.
+ */
+ @Test
+ void requirementFieldCoversEveryPersistedRecordComponent() {
+ List components = java.util.Arrays.stream(
+ ExtractedRequirements.class.getRecordComponents())
+ .map(java.lang.reflect.RecordComponent::getName)
+ .map(RequirementDiffTest::screamingSnake)
+ .toList();
+
+ assertThat(java.util.Arrays.stream(RequirementField.values()).map(Enum::name).toList())
+ .containsExactlyElementsOf(components);
+ }
+
+ private static String screamingSnake(String camelCase) {
+ return camelCase.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase(Locale.ROOT);
+ }
+
+ private ExtractedRequirements base() {
+ return new ExtractedRequirements("JUNIOR", false, null, null, false,
+ List.of("Java"), List.of("Java"), List.of(), "Bucharest",
+ "Romania eligible", List.of(), null, null, null, "DETERMINISTIC");
+ }
+
+ private ExtractedRequirements withSeniority(ExtractedRequirements value, String seniority) {
+ return new ExtractedRequirements(seniority, value.internshipOrTrainee(),
+ value.requiredExperienceYears(), value.requiredEducation(),
+ value.finalYearMandatory(), value.technologies(), value.programmingLanguages(),
+ value.spokenLanguages(), value.location(), value.remoteEligibility(),
+ value.mentorshipSignals(), value.workAuthorization(), value.salary(),
+ value.applicationDeadline(), value.extractionMethod());
+ }
+
+ private ExtractedRequirements withTechnologies(ExtractedRequirements value,
+ List technologies) {
+ return new ExtractedRequirements(value.seniority(), value.internshipOrTrainee(),
+ value.requiredExperienceYears(), value.requiredEducation(),
+ value.finalYearMandatory(), technologies, value.programmingLanguages(),
+ value.spokenLanguages(), value.location(), value.remoteEligibility(),
+ value.mentorshipSignals(), value.workAuthorization(), value.salary(),
+ value.applicationDeadline(), value.extractionMethod());
+ }
+
+ private ExtractedRequirements withEducation(ExtractedRequirements value, String education) {
+ return new ExtractedRequirements(value.seniority(), value.internshipOrTrainee(),
+ value.requiredExperienceYears(), education,
+ value.finalYearMandatory(), value.technologies(), value.programmingLanguages(),
+ value.spokenLanguages(), value.location(), value.remoteEligibility(),
+ value.mentorshipSignals(), value.workAuthorization(), value.salary(),
+ value.applicationDeadline(), value.extractionMethod());
+ }
+
+ /** The diff is a private implementation detail; the test reaches it without widening it. */
+ private static final class ReflectionTestSupport {
+ @SuppressWarnings("unchecked")
+ static List requirementDiff(ScoreRescorePreviewService service,
+ ExtractedRequirements stored,
+ ExtractedRequirements computed) {
+ try {
+ Method method = ScoreRescorePreviewService.class.getDeclaredMethod(
+ "requirementDiff", ExtractedRequirements.class,
+ ExtractedRequirements.class);
+ method.setAccessible(true);
+ return (List) method.invoke(service, stored, computed);
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException("Could not invoke requirementDiff", failure);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewReportRendererTest.java b/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewReportRendererTest.java
new file mode 100644
index 0000000..2c5d860
--- /dev/null
+++ b/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewReportRendererTest.java
@@ -0,0 +1,76 @@
+package com.jobpilot.matching.preview;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.BoundaryCrossings;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.ChangeCounts;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.QueueProjection;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementField;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementFieldChange;
+import com.jobpilot.matching.preview.ScoreRescorePreviewReport.RequirementsPreview;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/** The rendered preview is what an operator approves a write against. */
+class ScoreRescorePreviewReportRendererTest {
+ private static final String DESCRIPTION =
+ "Confidential posting body that must never reach an operational log";
+ private static final String URL = "https://example.com/jobs/secret-posting";
+
+ private final ScoreRescorePreviewReportRenderer renderer =
+ new ScoreRescorePreviewReportRenderer();
+
+ @Test
+ void namesEveryChangedRequirementFieldWithBothValues() {
+ List lines = renderer.render(ScoreRescorePreviewResult.success(report()));
+
+ String requirementsLine = lines.stream()
+ .filter(line -> line.startsWith("SCORE_RESCORE_PREVIEW_REQUIREMENTS"))
+ .findFirst().orElseThrow();
+
+ assertThat(requirementsLine).contains("jobId=42", "scoreChanged=false", "score=56->56",
+ "changedFields=2",
+ "SENIORITY=\"JUNIOR\"->\"UNKNOWN\"",
+ "TECHNOLOGIES=\"[Java, Go]\"->\"[Java]\"");
+ }
+
+ @Test
+ void exposesThePlanCountsInTheHeader() {
+ List lines = renderer.render(ScoreRescorePreviewResult.success(report()));
+
+ assertThat(lines).anySatisfy(line -> assertThat(line).contains(
+ "SCORE_RESCORE_PREVIEW_PLAN scoreChanged=0", "requirementsChanged=1",
+ "requirementsOnlyChanged=1", "changedPlan=1", "unchanged=2"));
+ assertThat(lines).anySatisfy(line -> assertThat(line).contains("scoreDeltaChanged=0"));
+ }
+
+ @Test
+ void neverPrintsDescriptionsOrUrls() {
+ List lines = renderer.render(ScoreRescorePreviewResult.success(report()));
+
+ assertThat(lines).noneSatisfy(line -> assertThat(line).contains(DESCRIPTION));
+ assertThat(lines).noneSatisfy(line -> assertThat(line).contains(URL));
+ }
+
+ @Test
+ void keepsEveryLineWithinTheLineBound() {
+ List lines = renderer.render(ScoreRescorePreviewResult.success(report()));
+
+ assertThat(lines).allSatisfy(line -> assertThat(line.length())
+ .isLessThanOrEqualTo(ScoreRescorePreviewReportRenderer.MAX_LINE_LENGTH));
+ }
+
+ private ScoreRescorePreviewReport report() {
+ QueueProjection queue = new QueueProjection(List.of(), List.of());
+ RequirementsPreview requirements = new RequirementsPreview(42L, "Java Developer", false,
+ 56, 56, List.of(
+ new RequirementFieldChange(RequirementField.SENIORITY, "JUNIOR", "UNKNOWN"),
+ new RequirementFieldChange(RequirementField.TECHNOLOGIES,
+ "[Java, Go]", "[Java]")));
+ return new ScoreRescorePreviewReport(3, 3, 0, 0, 0, 0, 0, 0, Map.of(), 0, 0,
+ new BoundaryCrossings(List.of(), List.of(), List.of()), List.of(),
+ queue, queue, List.of(), null,
+ new ChangeCounts(0, 1, 1, 1, 2), List.of(requirements));
+ }
+}
diff --git a/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java b/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java
index ee47a61..7a14537 100644
--- a/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java
+++ b/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java
@@ -123,12 +123,16 @@ void genuineSeniorEngineerRemainsBlockedAndUnchanged() {
assertThat(result.report().exactMatches()).isEqualTo(1);
assertThat(result.report().changedJobs()).isEmpty();
- assertThat(result.report().changedScoreCount()).isZero();
+ assertThat(result.report().scoreDeltaChangedCount()).isZero();
assertNoRepositoryWrites();
}
+ /**
+ * The write transaction re-persists requirements whenever they differ, so requirements-only
+ * drift must enter the plan and be visible in the preview instead of being written unseen.
+ */
@Test
- void requirementsOnlyDifferenceDoesNotEnterScoreWritePlan() {
+ void requirementsOnlyDifferenceEntersThePlanAndIsVisibleInThePreview() {
Job target = ordinaryMatch(10L);
ScoreCalculation current = calculator.calculate(target);
ExtractedRequirements stored = withSalary(current.requirements(), "legacy salary text");
@@ -138,12 +142,87 @@ void requirementsOnlyDifferenceDoesNotEnterScoreWritePlan() {
assertThat(result.status())
.isEqualTo(ScoreRescorePlanResult.Status.SUCCESS);
- assertThat(result.plan().report().exactMatches()).isEqualTo(1);
- assertThat(result.plan().changedCount()).isZero();
+ var report = result.plan().report();
+ assertThat(report.changeCounts().scoreChangedCount()).isZero();
+ assertThat(report.changeCounts().requirementsChangedCount()).isEqualTo(1);
+ assertThat(report.changeCounts().requirementsOnlyChangedCount()).isEqualTo(1);
+ assertThat(report.changeCounts().changedPlanCount()).isEqualTo(1);
+ assertThat(report.changeCounts().unchangedCount()).isZero();
+ assertThat(result.plan().changedCount()).isEqualTo(1);
+ assertThat(report.changedJobs()).isEmpty();
+ assertThat(report.requirementsChangedJobs()).singleElement().satisfies(preview -> {
+ assertThat(preview.jobId()).isEqualTo(10L);
+ assertThat(preview.scoreChanged()).isFalse();
+ assertThat(preview.storedScore()).isEqualTo(preview.computedScore());
+ assertThat(preview.changedFields()).singleElement().satisfies(change -> {
+ assertThat(change.field())
+ .isEqualTo(ScoreRescorePreviewReport.RequirementField.SALARY);
+ assertThat(change.storedValue()).isEqualTo("legacy salary text");
+ assertThat(change.computedValue()).isEqualTo("(null)");
+ });
+ });
+ assertNoRepositoryWrites();
+ }
+
+ @Test
+ void unchangedRowEntersNeitherPlanNorPreviewLists() {
+ Job target = ordinaryMatch(11L);
+ ScoreCalculation current = calculator.calculate(target);
+ stub(List.of(row(target, current.score())),
+ List.of(requirement(target, current.requirements())));
+
+ var result = service.plan(250);
+
+ var report = result.plan().report();
+ assertThat(report.changeCounts().changedPlanCount()).isZero();
+ assertThat(report.changeCounts().unchangedCount()).isEqualTo(1);
+ assertThat(report.changedJobs()).isEmpty();
+ assertThat(report.requirementsChangedJobs()).isEmpty();
assertThat(result.plan().entries()).isEmpty();
assertNoRepositoryWrites();
}
+ /** Every invariant the guarded write relies on, asserted together over one mixed corpus. */
+ @Test
+ void previewCountsAndPlanStayConsistentAcrossMixedChanges() {
+ Job scoreOnly = juniorProgramme(20L);
+ ScoreCalculation scoreOnlyCurrent = calculator.calculate(scoreOnly);
+ Job requirementsOnly = ordinaryMatch(21L);
+ ScoreCalculation requirementsOnlyCurrent = calculator.calculate(requirementsOnly);
+ Job unchanged = ordinaryMatch(22L);
+ ScoreCalculation unchangedCurrent = calculator.calculate(unchanged);
+
+ stub(List.of(row(scoreOnly, staleScore(scoreOnlyCurrent.score())),
+ row(requirementsOnly, requirementsOnlyCurrent.score()),
+ row(unchanged, unchangedCurrent.score())),
+ List.of(requirement(scoreOnly, scoreOnlyCurrent.requirements()),
+ requirement(requirementsOnly,
+ withSalary(requirementsOnlyCurrent.requirements(), "legacy")),
+ requirement(unchanged, unchangedCurrent.requirements())));
+
+ var plan = service.plan(250).plan();
+ var counts = plan.report().changeCounts();
+
+ assertThat(counts.changedPlanCount()).isEqualTo(plan.changedCount());
+ assertThat(counts.scoreChangedCount() + counts.requirementsOnlyChangedCount())
+ .isEqualTo(counts.changedPlanCount());
+ assertThat(plan.report().inspectedJobs())
+ .isEqualTo(counts.changedPlanCount() + counts.unchangedCount());
+ assertThat(plan.report().changedJobs()).hasSize(counts.scoreChangedCount());
+ assertThat(plan.report().requirementsChangedJobs())
+ .hasSize(counts.requirementsChangedCount());
+ assertThat(plan.report().scoreDeltaChangedCount())
+ .isLessThanOrEqualTo(counts.scoreChangedCount());
+
+ Set represented = new java.util.HashSet<>();
+ plan.report().changedJobs().forEach(job -> represented.add(job.jobId()));
+ plan.report().requirementsChangedJobs().forEach(job -> represented.add(job.jobId()));
+ assertThat(represented).containsAll(plan.changedJobIds());
+ plan.report().requirementsChangedJobs()
+ .forEach(job -> assertThat(job.changedFields()).isNotEmpty());
+ assertNoRepositoryWrites();
+ }
+
@Test
void missingRequirementsRowFailsClosed() {
Job target = juniorProgramme(3L);
@@ -299,6 +378,15 @@ private ExtractedRequirements withSeniority(ExtractedRequirements value, String
value.applicationDeadline(), value.extractionMethod());
}
+ /** A stored card that differs from the freshly computed one by score alone. */
+ private ScoreCard staleScore(ScoreCard fresh) {
+ return new ScoreCard(fresh.score() - 5, fresh.band(), fresh.suitable(),
+ fresh.formalEligibility(), fresh.javaBackend(), fresh.traineeQuality(),
+ fresh.supportingTechnology(), fresh.locationFormat(),
+ fresh.experienceCompatibility(), fresh.freshness(), fresh.penalties(),
+ fresh.strengths(), fresh.risks(), fresh.hardBlockers());
+ }
+
private ExtractedRequirements withSalary(ExtractedRequirements value, String salary) {
return new ExtractedRequirements(value.seniority(), value.internshipOrTrainee(),
value.requiredExperienceYears(), value.requiredEducation(),
diff --git a/src/test/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntryChangeTest.java b/src/test/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntryChangeTest.java
new file mode 100644
index 0000000..2d95c33
--- /dev/null
+++ b/src/test/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntryChangeTest.java
@@ -0,0 +1,70 @@
+package com.jobpilot.matching.rescore;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.jobpilot.jobs.domain.ExtractedRequirements;
+import com.jobpilot.jobs.domain.ScreeningDisposition;
+import com.jobpilot.matching.ScoreBand;
+import com.jobpilot.matching.ScoreCard;
+import java.time.Instant;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Plan membership must follow either persisted projection, not the score alone. */
+class ScoreRescorePlanEntryChangeTest {
+
+ @Test
+ void scoreOnlyChangeIsPlanned() {
+ var entry = entry(card(50), card(56), requirements("JUNIOR"), requirements("JUNIOR"));
+
+ assertThat(entry.changed()).isTrue();
+ assertThat(entry.scoreChanged()).isTrue();
+ assertThat(entry.requirementsChanged()).isFalse();
+ }
+
+ @Test
+ void requirementsOnlyChangeIsPlanned() {
+ var entry = entry(card(56), card(56), requirements("JUNIOR"), requirements("UNKNOWN"));
+
+ assertThat(entry.changed()).isTrue();
+ assertThat(entry.scoreChanged()).isFalse();
+ assertThat(entry.requirementsChanged()).isTrue();
+ }
+
+ @Test
+ void bothChangedIsPlanned() {
+ var entry = entry(card(50), card(56), requirements("JUNIOR"), requirements("UNKNOWN"));
+
+ assertThat(entry.changed()).isTrue();
+ assertThat(entry.scoreChanged()).isTrue();
+ assertThat(entry.requirementsChanged()).isTrue();
+ }
+
+ @Test
+ void unchangedRowIsNotPlanned() {
+ var entry = entry(card(56), card(56), requirements("JUNIOR"), requirements("JUNIOR"));
+
+ assertThat(entry.changed()).isFalse();
+ assertThat(entry.scoreChanged()).isFalse();
+ assertThat(entry.requirementsChanged()).isFalse();
+ }
+
+ private ScoreRescorePlanEntry entry(ScoreCard storedScore, ScoreCard computedScore,
+ ExtractedRequirements storedRequirements,
+ ExtractedRequirements computedRequirements) {
+ return new ScoreRescorePlanEntry(5, 10, 20, ScreeningDisposition.REVIEW,
+ "description", "content", Instant.EPOCH, "json",
+ storedScore, storedRequirements, computedScore, computedRequirements);
+ }
+
+ private ScoreCard card(int score) {
+ return new ScoreCard(score, ScoreBand.POSSIBLE_MATCH, true, 25, 9, 3, 0, 8, 10, 1, 0,
+ List.of(), List.of(), List.of());
+ }
+
+ private ExtractedRequirements requirements(String seniority) {
+ return new ExtractedRequirements(seniority, false, null, null, false,
+ List.of("Java"), List.of("Java"), List.of(), "Bucharest",
+ "Romania eligible", List.of(), null, null, null, "DETERMINISTIC");
+ }
+}
diff --git a/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteCoordinatorTest.java b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteCoordinatorTest.java
index 6049bbf..8fa3068 100644
--- a/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteCoordinatorTest.java
+++ b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteCoordinatorTest.java
@@ -83,7 +83,7 @@ void everyMatchingGuardInvokesTransactionExactlyOnce() {
@Test
void oldFingerprintCannotBeAppliedToThePostWriteEmptyPlan() {
- ScoreRescorePlan empty = new ScoreRescorePlan(report(1), List.of());
+ ScoreRescorePlan empty = new ScoreRescorePlan(report(1, 0), List.of());
ScoreRescoreWriteResult result = coordinator.execute(empty, properties(true, "1",
plan.fingerprint(), "250", ScoreRescoreCommandGuards.CONFIRMATION));
@@ -118,11 +118,19 @@ private ScoreRescorePlan plan() {
}
private ScoreRescorePreviewReport report(int inspected) {
+ return report(inspected, 1);
+ }
+
+ /** The plan now asserts its entry count against the report, so fixtures must agree. */
+ private ScoreRescorePreviewReport report(int inspected, int changedPlan) {
var queue = new ScoreRescorePreviewReport.QueueProjection(List.of(), List.of());
return new ScoreRescorePreviewReport(inspected, inspected - 1, 1, 1, 1, 0,
1, 0, Map.of(56, 1), 1, 0,
new ScoreRescorePreviewReport.BoundaryCrossings(List.of(5L), List.of(5L),
- List.of()), List.of(), queue, queue, List.of(), null);
+ List.of()), List.of(), queue, queue, List.of(), null,
+ new ScoreRescorePreviewReport.ChangeCounts(changedPlan, changedPlan, 0,
+ changedPlan, inspected - changedPlan),
+ List.of());
}
private ExtractedRequirements requirements(String seniority) {
diff --git a/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java
index dd2447b..22dffee 100644
--- a/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java
+++ b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java
@@ -173,7 +173,10 @@ private ScoreRescorePlan plan(Target... targets) {
var report = new ScoreRescorePreviewReport(targets.length, 0, targets.length, 0, 0, 0,
0, 0, Map.of(), 0, 0,
new ScoreRescorePreviewReport.BoundaryCrossings(List.of(), List.of(), List.of()),
- List.of(), queue, queue, List.of(), null);
+ List.of(), queue, queue, List.of(), null,
+ new ScoreRescorePreviewReport.ChangeCounts(entries.size(), entries.size(), 0,
+ entries.size(), targets.length - entries.size()),
+ List.of());
return new ScoreRescorePlan(report, entries);
}