Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/main/java/com/jobpilot/matching/JobMatchingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public ScoreCard score(Job job, ExtractedRequirements r) {
if (experience >= 8) strengths.add("Commercial experience expectations are entry-level compatible");

int freshness = freshness(job);
if (freshness >= 4) strengths.add("Vacancy is recent and appears open");
if (freshness >= 4) strengths.add(ScoreCard.FRESHNESS_STRENGTH);

int penalties = 0;
Double years = r.requiredExperienceYears();
Expand Down Expand Up @@ -103,9 +103,21 @@ else if (years != null && years >= 2) {
|| has(text, "position closed", "no longer accepting applications");
if (expired) blockers.add("Vacancy is closed or expired");

int raw = formal + backend + trainee + supporting + location + experience + freshness - penalties;
// Freshness is intentionally observable but excluded from semantic fit. A vacancy must
// not change its persisted compatibility score merely because the clock crossed an age
// boundary; recency belongs to ordering and explanation rather than candidate fit.
int raw = formal + backend + trainee + supporting + location + experience - penalties;
int total = blockers.isEmpty() ? Math.clamp(raw, 0, 100) : 0;
boolean suitable = blockers.isEmpty();

// Generic eligibility and supporting tooling cannot establish a Java/backend match by
// themselves. Without one confirmed candidate backend skill, keep the vacancy below the
// POSSIBLE_MATCH boundary while preserving its relative LOW_MATCH score.
if (suitable && backendMatches == 0 && total >= 55) {
total = 54;
risks.add("No confirmed Java/backend target-stack evidence");
}

ScoreBand band = !suitable ? ScoreBand.UNSUITABLE : total >= 85 ? ScoreBand.EXCELLENT_MATCH
: total >= 70 ? ScoreBand.GOOD_MATCH : total >= 55 ? ScoreBand.POSSIBLE_MATCH : ScoreBand.LOW_MATCH;
return new ScoreCard(total, band, suitable, formal, backend, trainee, supporting, location,
Expand Down
1 change: 0 additions & 1 deletion src/main/java/com/jobpilot/matching/ScoreCalculation.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public int rawComponentTotal() {
+ score.supportingTechnology()
+ score.locationFormat()
+ score.experienceCompatibility()
+ score.freshness()
- score.penalties();
}
}
31 changes: 31 additions & 0 deletions src/main/java/com/jobpilot/matching/ScoreCard.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jobpilot.matching;

import java.util.List;
import java.util.Objects;

public record ScoreCard(
int score,
Expand All @@ -17,4 +18,34 @@ public record ScoreCard(
List<String> strengths,
List<String> risks,
List<String> hardBlockers) {

static final String FRESHNESS_STRENGTH =
"Vacancy is recent and appears open";

/**
* Candidate/job fit equality. Freshness is observable metadata rather than semantic fit,
* including the explanatory strength derived solely from that freshness bucket.
*/
public boolean semanticEquals(ScoreCard other) {
if (other == null) return false;
return score == other.score
&& band == other.band
&& suitable == other.suitable
&& formalEligibility == other.formalEligibility
&& javaBackend == other.javaBackend
&& traineeQuality == other.traineeQuality
&& supportingTechnology == other.supportingTechnology
&& locationFormat == other.locationFormat
&& experienceCompatibility == other.experienceCompatibility
&& penalties == other.penalties
&& Objects.equals(semanticStrengths(), other.semanticStrengths())
&& Objects.equals(risks, other.risks)
&& Objects.equals(hardBlockers, other.hardBlockers);
}

public List<String> semanticStrengths() {
return strengths.stream()
.filter(value -> !FRESHNESS_STRENGTH.equals(value))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ private ScoreRescorePreviewReport buildReport(List<EvaluatedJob> jobs) {
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);
// Identical to the semantic equality that decides plan membership.
boolean scoreDiffers = !oldScore.semanticEquals(newScore);
boolean requirementsDiffer = !oldRequirements.equals(newRequirements);
if (scoreDiffers) scoreChanged++;
if (requirementsDiffer) requirementsChanged++;
Expand All @@ -262,7 +262,7 @@ private ScoreRescorePreviewReport buildReport(List<EvaluatedJob> jobs) {
}
int delta = newScore.score() - oldScore.score();
deltas.merge(delta, 1, Integer::sum);
if (oldScore.equals(newScore)) exact++;
if (oldScore.semanticEquals(newScore)) exact++;
if (delta != 0) changedScore++;
if (oldScore.band() != newScore.band()) changedBand++;
if (removed(oldScore.hardBlockers(), newScore.hardBlockers())) blockerRemoved++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,37 @@ public static ScoreRescorePlanEntry snapshot(JobScore scoreRow,
* a withdrawn dirty technology token — stayed permanently unreachable.
*/
public boolean changed() {
return !storedScore.equals(computedScore)
|| !storedRequirements.equals(computedRequirements);
return scoreChanged() || requirementsChanged();
}

public boolean scoreChanged() {
return !storedScore.equals(computedScore);
return !storedScore.semanticEquals(computedScore);
}

public boolean requirementsChanged() {
return !storedRequirements.equals(computedRequirements);
}

/**
* The persisted side of an approved plan must still match exactly at write time.
* The recomputed side may differ only in non-semantic freshness metadata.
*/
public boolean sameWriteGuardState(ScoreRescorePlanEntry other) {
if (other == null) return false;
return jobId == other.jobId
&& scoreRowId == other.scoreRowId
&& requirementRowId == other.requirementRowId
&& screeningDisposition == other.screeningDisposition
&& Objects.equals(descriptionHash, other.descriptionHash)
&& Objects.equals(sourceContentHash, other.sourceContentHash)
&& Objects.equals(storedScoredAt, other.storedScoredAt)
&& Objects.equals(storedRequirementJsonHash, other.storedRequirementJsonHash)
&& Objects.equals(storedScore, other.storedScore)
&& Objects.equals(storedRequirements, other.storedRequirements)
&& computedScore.semanticEquals(other.computedScore)
&& Objects.equals(computedRequirements, other.computedRequirements);
}

private static long requireId(Long id, String type) {
if (id == null || id < 1) {
throw new IllegalArgumentException("Persisted " + type + " identity is required");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

/** Versioned, length-prefixed canonical SHA-256 identity for one immutable plan. */
public final class ScoreRescorePlanFingerprint {
public static final String FORMAT_VERSION = "jobpilot-score-rescore-plan-v1";
public static final String FORMAT_VERSION = "jobpilot-score-rescore-plan-v2";

private ScoreRescorePlanFingerprint() {
}
Expand Down Expand Up @@ -44,14 +44,14 @@ private static void append(DataOutputStream output, ScoreRescorePlanEntry value)
field(output, "sourceContentHash", value.sourceContentHash());
field(output, "storedScoredAt", value.storedScoredAt().toString());
field(output, "storedRequirementJsonHash", value.storedRequirementJsonHash());
score(output, "oldScore", value.storedScore());
score(output, "oldScore", value.storedScore(), false);
requirements(output, "oldRequirements", value.storedRequirements());
score(output, "newScore", value.computedScore());
score(output, "newScore", value.computedScore(), true);
requirements(output, "newRequirements", value.computedRequirements());
}

private static void score(DataOutputStream output, String prefix, ScoreCard value)
throws IOException {
private static void score(DataOutputStream output, String prefix, ScoreCard value,
boolean semantic) throws IOException {
field(output, prefix + ".score", Integer.toString(value.score()));
field(output, prefix + ".band", value.band().name());
field(output, prefix + ".suitable", Boolean.toString(value.suitable()));
Expand All @@ -63,9 +63,12 @@ private static void score(DataOutputStream output, String prefix, ScoreCard valu
field(output, prefix + ".locationFormat", Integer.toString(value.locationFormat()));
field(output, prefix + ".experienceCompatibility",
Integer.toString(value.experienceCompatibility()));
field(output, prefix + ".freshness", Integer.toString(value.freshness()));
if (!semantic) {
field(output, prefix + ".freshness", Integer.toString(value.freshness()));
}
field(output, prefix + ".penalties", Integer.toString(value.penalties()));
list(output, prefix + ".strengths", value.strengths(), false);
list(output, prefix + ".strengths",
semantic ? value.semanticStrengths() : value.strengths(), false);
list(output, prefix + ".penaltyRisks", value.risks(), true);
list(output, prefix + ".blockers", value.hardBlockers(), true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public ScoreRescoreWriteResult apply(ScoreRescorePlan plan) {
ScoreCalculation computed = calculator.calculate(scoreRow.getJob());
ScoreRescorePlanEntry current = ScoreRescorePlanEntry.snapshot(
scoreRow, requirementRow, computed);
if (!current.equals(expected)) {
if (!current.sameWriteGuardState(expected)) {
throw abort("A planned target changed after planning");
}
String rawJson = json(computed.requirements());
Expand Down
179 changes: 179 additions & 0 deletions src/test/java/com/jobpilot/matching/ScoringSemanticsContractTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package com.jobpilot.matching;

import static org.assertj.core.api.Assertions.assertThat;

import com.jobpilot.common.UrlCanonicalizer;
import com.jobpilot.extraction.DeterministicRequirementExtractor;
import com.jobpilot.jobs.domain.Job;
import com.jobpilot.jobs.domain.RawJob;
import com.jobpilot.jobs.domain.ScreeningDisposition;
import com.jobpilot.jobs.service.JobNormalizer;
import com.jobpilot.jobs.service.JobRelevanceFilter;
import com.jobpilot.support.TestProperties;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;

class ScoringSemanticsContractTest {

private static final Instant NOW = Instant.parse("2026-07-17T12:00:00Z");

private final DeterministicRequirementExtractor extractor =
new DeterministicRequirementExtractor();

@Test
void rejectedNonEngineeringRoleCannotReachPossibleMatchWithoutBackendEvidence() {
String description = """
Graduate training programme for current students with a Computer Science
or related-field background.

Work from Bucharest, Romania with engineering and product teams.
The organisation uses React, TypeScript, JavaScript, HTML, CSS,
Git, CI/CD and GitHub Actions.
""";

RawJob raw = new RawJob(
"contract",
"product-marketing-graduate",
"https://example.com/jobs/product-marketing-graduate",
"Product Marketing Graduate",
"Example",
"Bucharest, Romania",
description,
"Full-time",
NOW,
null,
description);

var properties = TestProperties.create();
var relevance = new JobRelevanceFilter(properties).evaluate(raw);

Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
Job job = new JobNormalizer(clock, new UrlCanonicalizer()).normalize(raw);
ScoreCard score = new JobMatchingService(clock, properties)
.score(job, extractor.extract(job));

assertThat(relevance.disposition()).isEqualTo(ScreeningDisposition.REJECT);
assertThat(score.javaBackend()).isZero();
assertThat(score.score()).isLessThan(55);
assertThat(score.band()).isEqualTo(ScoreBand.LOW_MATCH);
}

@Test
void zeroBackendEvidenceCannotReachPossibleMatch() {
ScoreCard score = score(
Clock.fixed(NOW, ZoneOffset.UTC),
"Implementation Engineer",
"""
Current students and graduates with a Computer Science background are welcome.
Work in Bucharest, Romania.

Tools include React, TypeScript, JavaScript, HTML, CSS,
Git, CI/CD and GitHub Actions.
""",
NOW);

assertThat(score.javaBackend()).isZero();

// A Java/backend candidate should not receive semantic match status
// from generic eligibility and supporting tooling alone.
assertThat(score.score()).isLessThan(55);
assertThat(score.band()).isEqualTo(ScoreBand.LOW_MATCH);
}

@Test
void targetBackendEvidenceMustOutrankSupportingOnlyProfile() {
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);

ScoreCard supportingOnly = score(
clock,
"Implementation Engineer",
"""
Current students and graduates with a Computer Science background are welcome.
Work in Bucharest, Romania.
Tools include React, TypeScript, JavaScript, HTML, CSS,
Git, CI/CD and GitHub Actions.
""",
NOW);

ScoreCard javaBackend = score(
clock,
"Java Backend Engineer",
"""
Current students and graduates with a Computer Science background are welcome.
Build Java services with Spring Boot, REST APIs, SQL and PostgreSQL.
Work in Bucharest, Romania.
""",
NOW);

assertThat(javaBackend.javaBackend())
.isGreaterThan(supportingOnly.javaBackend());

assertThat(javaBackend.score())
.isGreaterThan(supportingOnly.score());
}

@Test
void freshnessChangesComponentButNotSemanticFitScore() {
Instant published = Instant.parse("2026-07-17T12:00:00Z");

Clock day14 = Clock.fixed(
Instant.parse("2026-07-31T12:00:00Z"),
ZoneOffset.UTC);

Clock day15 = Clock.fixed(
Instant.parse("2026-08-01T12:00:00Z"),
ZoneOffset.UTC);

String description = """
Build Java services with Spring Boot.
Work from Bucharest, Romania.
""";

ScoreCard at14 = score(
day14,
"Java Backend Engineer",
description,
published);

ScoreCard at15 = score(
day15,
"Java Backend Engineer",
description,
published);

assertThat(at14.freshness()).isGreaterThan(at15.freshness());

// Recency may remain observable for ordering/explanation,
// but must not mutate the persisted semantic fit score.
assertThat(at14.score()).isEqualTo(at15.score());
assertThat(at14.band()).isEqualTo(at15.band());
}

private ScoreCard score(
Clock clock,
String title,
String description,
Instant publishedAt) {

RawJob raw = new RawJob(
"contract",
title.toLowerCase().replaceAll("[^a-z0-9]+", "-"),
"https://example.com/jobs/"
+ title.toLowerCase().replaceAll("[^a-z0-9]+", "-"),
title,
"Example",
"Bucharest, Romania",
description,
"Full-time",
publishedAt,
null,
description);

Job job = new JobNormalizer(clock, new UrlCanonicalizer()).normalize(raw);

return new JobMatchingService(clock, TestProperties.create())
.score(job, extractor.extract(job));
}
}
Loading
Loading