From 705a4470245b1242892bd99a51e2381890b224c3 Mon Sep 17 00:00:00 2001 From: Pavlo Sushkov Date: Tue, 11 Aug 2026 21:15:46 +0300 Subject: [PATCH] fix: separate freshness from scoring semantics --- .../jobpilot/matching/JobMatchingService.java | 16 +- .../jobpilot/matching/ScoreCalculation.java | 1 - .../java/com/jobpilot/matching/ScoreCard.java | 31 +++ .../preview/ScoreRescorePreviewService.java | 6 +- .../rescore/ScoreRescorePlanEntry.java | 25 ++- .../rescore/ScoreRescorePlanFingerprint.java | 17 +- .../rescore/ScoreRescoreWriteTransaction.java | 2 +- .../ScoringSemanticsContractTest.java | 179 ++++++++++++++++++ .../ScoreRescorePreviewServiceTest.java | 10 +- .../ScoreRescoreFreshnessSemanticsTest.java | 154 +++++++++++++++ .../ScoreRescoreWriteTransactionTest.java | 4 +- 11 files changed, 421 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/jobpilot/matching/ScoringSemanticsContractTest.java create mode 100644 src/test/java/com/jobpilot/matching/rescore/ScoreRescoreFreshnessSemanticsTest.java diff --git a/src/main/java/com/jobpilot/matching/JobMatchingService.java b/src/main/java/com/jobpilot/matching/JobMatchingService.java index 400628f..05d6173 100644 --- a/src/main/java/com/jobpilot/matching/JobMatchingService.java +++ b/src/main/java/com/jobpilot/matching/JobMatchingService.java @@ -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(); @@ -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, diff --git a/src/main/java/com/jobpilot/matching/ScoreCalculation.java b/src/main/java/com/jobpilot/matching/ScoreCalculation.java index 136aef6..47b210f 100644 --- a/src/main/java/com/jobpilot/matching/ScoreCalculation.java +++ b/src/main/java/com/jobpilot/matching/ScoreCalculation.java @@ -18,7 +18,6 @@ public int rawComponentTotal() { + score.supportingTechnology() + score.locationFormat() + score.experienceCompatibility() - + score.freshness() - score.penalties(); } } diff --git a/src/main/java/com/jobpilot/matching/ScoreCard.java b/src/main/java/com/jobpilot/matching/ScoreCard.java index c95ed37..76cdd06 100644 --- a/src/main/java/com/jobpilot/matching/ScoreCard.java +++ b/src/main/java/com/jobpilot/matching/ScoreCard.java @@ -1,6 +1,7 @@ package com.jobpilot.matching; import java.util.List; +import java.util.Objects; public record ScoreCard( int score, @@ -17,4 +18,34 @@ public record ScoreCard( List strengths, List risks, List 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 semanticStrengths() { + return strengths.stream() + .filter(value -> !FRESHNESS_STRENGTH.equals(value)) + .toList(); + } } diff --git a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java index a4cddde..372f1ee 100644 --- a/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java +++ b/src/main/java/com/jobpilot/matching/preview/ScoreRescorePreviewService.java @@ -247,8 +247,8 @@ private ScoreRescorePreviewReport buildReport(List 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++; @@ -262,7 +262,7 @@ private ScoreRescorePreviewReport buildReport(List 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++; diff --git a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java index 4ab6fb2..ece1007 100644 --- a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java +++ b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanEntry.java @@ -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"); diff --git a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanFingerprint.java b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanFingerprint.java index 2d02c5b..0d1b3ca 100644 --- a/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanFingerprint.java +++ b/src/main/java/com/jobpilot/matching/rescore/ScoreRescorePlanFingerprint.java @@ -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() { } @@ -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())); @@ -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); } diff --git a/src/main/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransaction.java b/src/main/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransaction.java index f84c32d..e6ec9fb 100644 --- a/src/main/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransaction.java +++ b/src/main/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransaction.java @@ -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()); diff --git a/src/test/java/com/jobpilot/matching/ScoringSemanticsContractTest.java b/src/test/java/com/jobpilot/matching/ScoringSemanticsContractTest.java new file mode 100644 index 0000000..d5c8d5a --- /dev/null +++ b/src/test/java/com/jobpilot/matching/ScoringSemanticsContractTest.java @@ -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)); + } +} diff --git a/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java b/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java index 7a14537..adf8240 100644 --- a/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java +++ b/src/test/java/com/jobpilot/matching/preview/ScoreRescorePreviewServiceTest.java @@ -69,10 +69,10 @@ void setUp() { } @Test - void juniorJavaDeveloperPreviewsZeroTo56WithSeniorityBlockerRemovedAndQueueMove() { + void juniorJavaDeveloperPreviewsZeroTo55WithSeniorityBlockerRemovedAndQueueMove() { Job target = juniorProgramme(1L); ScoreCalculation fresh = calculator.calculate(target); - assertThat(fresh.score().score()).isEqualTo(56); + assertThat(fresh.score().score()).isEqualTo(55); ExtractedRequirements staleRequirements = withSeniority(fresh.requirements(), "MIDDLE"); ScoreCard staleScore = calculator.calculate(target, staleRequirements).score(); assertThat(staleScore.score()).isZero(); @@ -91,14 +91,14 @@ void juniorJavaDeveloperPreviewsZeroTo56WithSeniorityBlockerRemovedAndQueueMove( assertThat(targetResult).isNotNull(); assertThat(targetResult.stored().score()).isZero(); assertThat(targetResult.stored().band()).isEqualTo(ScoreBand.UNSUITABLE); - assertThat(targetResult.computed().score()).isEqualTo(56); + assertThat(targetResult.computed().score()).isEqualTo(55); assertThat(targetResult.computed().band()).isEqualTo(ScoreBand.POSSIBLE_MATCH); - assertThat(targetResult.delta()).isEqualTo(56); + assertThat(targetResult.delta()).isEqualTo(55); assertThat(targetResult.stored().blockers()).contains("Middle or senior seniority"); assertThat(targetResult.computed().blockers()).isEmpty(); assertThat(targetResult.stored().inferredSeniority()).isEqualTo("MIDDLE"); assertThat(targetResult.computed().inferredSeniority()).isEqualTo("JUNIOR"); - assertThat(targetResult.rawComponentTotal()).isEqualTo(56); + assertThat(targetResult.rawComponentTotal()).isEqualTo(55); assertThat(targetResult.causedBySeniorityExtractorFix()).isTrue(); assertThat(targetResult.telegramQueuePositionChanged()).isTrue(); assertThat(targetResult.oldQueuePosition()).isEqualTo(2); diff --git a/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreFreshnessSemanticsTest.java b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreFreshnessSemanticsTest.java new file mode 100644 index 0000000..c8b3e2a --- /dev/null +++ b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreFreshnessSemanticsTest.java @@ -0,0 +1,154 @@ +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.ScoreCalculation; +import com.jobpilot.matching.ScoreCard; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ScoreRescoreFreshnessSemanticsTest { + + @Test + void freshnessOnlyDifferenceDoesNotCreateRescorePlanEntry() { + ScoreCard stored = card(55, 5); + ScoreCard computed = card(55, 4); + ExtractedRequirements requirements = requirements(); + + var entry = new ScoreRescorePlanEntry( + 5, + 10, + 20, + ScreeningDisposition.REVIEW, + "description-hash", + "content-hash", + Instant.EPOCH, + "requirements-hash", + stored, + requirements, + computed, + requirements); + + assertThat(entry.scoreChanged()).isFalse(); + assertThat(entry.requirementsChanged()).isFalse(); + assertThat(entry.changed()).isFalse(); + } + + @Test + void rawComponentTotalExcludesNonSemanticFreshness() { + ScoreCard score = card(55, 5); + ScoreCalculation calculation = + new ScoreCalculation(requirements(), score); + + // 25 formal + // + 9 backend + // + 3 trainee + // + 0 supporting + // + 8 location + // + 10 experience + // - 0 penalties + // = 55 + // + // Freshness remains observable as 5 but is not semantic fit. + assertThat(score.freshness()).isEqualTo(5); + assertThat(calculation.rawComponentTotal()).isEqualTo(55); + } + + + @Test + void computedFreshnessDoesNotChangeSemanticPlanFingerprint() { + ExtractedRequirements requirements = requirements(); + ScoreCard stored = card(50, 5); + + var first = new ScoreRescorePlanEntry( + 5, 10, 20, ScreeningDisposition.REVIEW, + "description-hash", "content-hash", Instant.EPOCH, "requirements-hash", + stored, requirements, card(55, 5), requirements); + + var second = new ScoreRescorePlanEntry( + 5, 10, 20, ScreeningDisposition.REVIEW, + "description-hash", "content-hash", Instant.EPOCH, "requirements-hash", + stored, requirements, card(55, 4), requirements); + + assertThat(ScoreRescorePlanFingerprint.fingerprint(List.of(first))) + .isEqualTo(ScoreRescorePlanFingerprint.fingerprint(List.of(second))); + } + + + @Test + void writeGuardToleratesComputedFreshnessMetadataDriftButProtectsStoredState() { + ExtractedRequirements requirements = requirements(); + ScoreCard stored = card(50, 5); + + ScoreCard recent = card(55, 4, + List.of("Vacancy is recent and appears open")); + ScoreCard older = card(55, 2, List.of()); + + var expected = new ScoreRescorePlanEntry( + 5, 10, 20, ScreeningDisposition.REVIEW, + "description-hash", "content-hash", Instant.EPOCH, "requirements-hash", + stored, requirements, recent, requirements); + + var recomputedLater = new ScoreRescorePlanEntry( + 5, 10, 20, ScreeningDisposition.REVIEW, + "description-hash", "content-hash", Instant.EPOCH, "requirements-hash", + stored, requirements, older, requirements); + + var persistedStateChanged = new ScoreRescorePlanEntry( + 5, 10, 20, ScreeningDisposition.REVIEW, + "description-hash", "content-hash", Instant.EPOCH, "requirements-hash", + card(50, 4), requirements, older, requirements); + + assertThat(recent.semanticEquals(older)).isTrue(); + assertThat(recomputedLater.sameWriteGuardState(expected)).isTrue(); + assertThat(persistedStateChanged.sameWriteGuardState(expected)).isFalse(); + + assertThat(ScoreRescorePlanFingerprint.fingerprint(List.of(expected))) + .isEqualTo(ScoreRescorePlanFingerprint.fingerprint(List.of(recomputedLater))); + } + + private ScoreCard card(int score, int freshness) { + return card(score, freshness, List.of()); + } + + private ScoreCard card(int score, int freshness, List strengths) { + return new ScoreCard( + score, + ScoreBand.POSSIBLE_MATCH, + true, + 25, + 9, + 3, + 0, + 8, + 10, + freshness, + 0, + strengths, + List.of(), + List.of()); + } + + private ExtractedRequirements requirements() { + 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"); + } +} diff --git a/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java index 22dffee..113816c 100644 --- a/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java +++ b/src/test/java/com/jobpilot/matching/rescore/ScoreRescoreWriteTransactionTest.java @@ -64,7 +64,7 @@ void setUp() { } @Test - void juniorJavaFixtureWritesZeroTo56AndMiddleToJuniorInPlace() { + void juniorJavaFixtureWritesZeroTo55AndMiddleToJuniorInPlace() { Job job = job(1, "Code First Girls Programme - Junior Java Developer", "Build software services with Java, Spring Boot and SQL. Graduate training is " + "provided. The Code First Girls mid-level accelerator programme is " @@ -83,7 +83,7 @@ void juniorJavaFixtureWritesZeroTo56AndMiddleToJuniorInPlace() { assertThat(target.score().getId()).isEqualTo(1_001L); assertThat(target.requirement().getId()).isEqualTo(2_001L); assertThat(target.score().toValue()).isEqualTo(fresh.score()); - assertThat(target.score().getScore()).isEqualTo(56); + assertThat(target.score().getScore()).isEqualTo(55); assertThat(target.score().getBand()).isEqualTo(ScoreBand.POSSIBLE_MATCH); assertThat(target.requirement().toValue().seniority()).isEqualTo("JUNIOR"); assertThat(target.score().getScoredAt()).isEqualTo(NOW);