fix(rectification): bump scoring identity to scoring-10 for functional profile v2; dated contract by generation (BUG-1181)

Functional roles feed the *_functional_*_auxiliary rules, so 57782aea changes
candidate scores for identical input (memoization fixture 12:00: 8.6274 ->
8.4977). Per the "scoring semantics change => bump ALGORITHM_VERSION"
precedent (scoring-7 -> 8 -> 9), the identity moves to scoring-10; policy v3,
input contract v5 and Skill versions are unchanged, history is not relabeled.

Five frontend sites and one SQL guard tested `=== "...scoring-9"` for the
dated candidate-window contract; they now use isDatedScoringAlgorithmVersion /
a generation regex (>= 9). Migration 20261002010000 only recreates
validate_dated_rectification_candidate (one-line guard change).

Memoization golden v2 written by the test's own write_golden; v1 (scoring-8)
frozen by sha256. Real-engine scoring-10 cross-midnight golden added. Research
records re-frozen per ERR-110 (label functional_v2_2026_10_02) and
scripts/functional_benefics.py added to the frozen production identity
(ERR-114: 57782aea changed scores without tripping it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-02 12:31:50 +08:00
co-authored by Claude Opus 5.5
parent 57782aea8a
commit 3733b9787b
25 changed files with 51388 additions and 355 deletions
@@ -20,6 +20,23 @@ export type DatedCandidateRange = Readonly<{
midnight_side_pending?: boolean;
}>;
/**
* Scoring generations that carry the dated candidate-window contract. scoring-9
* introduced it (BUG-982/983) and every later generation keeps it; compare the
* generation number, never one literal, so a version bump (scoring-10 for the
* functional benefic / malefic profile v2, BUG-1181) cannot silently turn new
* results into legacy same-day ones. Mirrors the SQL guard in
* 20261002010000_rectification_dated_algorithm_generation.sql.
*/
export const FIRST_DATED_SCORING_GENERATION = 9;
const SCORING_ALGORITHM_VERSION = /^rectification-v5-matrix-scoring-([1-9][0-9]*)$/;
export function isDatedScoringAlgorithmVersion(value: unknown): boolean {
if (typeof value !== "string") return false;
const match = SCORING_ALGORITHM_VERSION.exec(value);
return match !== null && Number(match[1]) >= FIRST_DATED_SCORING_GENERATION;
}
export function parseCandidateIntervals(value: unknown): readonly CandidateInterval[] | null {
if (!Array.isArray(value) || value.length < 1 || value.length > 2) return null;
const rows: CandidateInterval[] = [];
@@ -26,7 +26,7 @@ import { questionContractVersionIsCompatible } from "./probe-question-contract";
import { resolveAyanamsa } from "../../ayanamsa.ts";
import { candidateSegmentMetadata } from "./segment-contract.ts";
import type { AscendantSignature } from "../core/segment-summary.ts";
import { requestCandidateIntervals, readCandidatePosition, type CandidatePosition, type DatedCandidateRange } from "../core/candidate-window.ts";
import { isDatedScoringAlgorithmVersion, requestCandidateIntervals, readCandidatePosition, type CandidatePosition, type DatedCandidateRange } from "../core/candidate-window.ts";
import { parseBlockScanPayload, type BlockScanBlock } from "./block-scan.ts";
import { recordEngineCallTiming, reportTurnProgress } from "./turn-instrumentation.ts";
@@ -987,7 +987,7 @@ export async function runV9CandidateScore(input: {
}): Promise<V9EngineScoreResult> {
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input), 60_000, { busyRetry: true });
const datedRange = { ...input.candidateRange, candidate_intervals: requestCandidateIntervals(input.baselineBirthSnapshot, input.candidateRange) };
const dated = data.algorithm_version === "rectification-v5-matrix-scoring-9";
const dated = isDatedScoringAlgorithmVersion(data.algorithm_version);
const metadata = candidateSegmentMetadata(data);
const candidates = readCandidates(data.candidate_decisions, datedRange, dated).map((candidate) => ({ ...candidate, ...metadata.byTime.get(candidate.time) }));
const receipt = readDecisionReceipt(data.decision_receipt, candidates);
@@ -1028,7 +1028,7 @@ export async function runV9Diagnostics(input: {
}): Promise<V9EngineDiagnostics> {
const data = await postEngine("/api/rectification/v5/diagnostics", engineRequestBody(input));
const datedRange = { ...input.candidateRange, candidate_intervals: requestCandidateIntervals(input.baselineBirthSnapshot, input.candidateRange) };
const dated = data.algorithm_version === "rectification-v5-matrix-scoring-9";
const dated = isDatedScoringAlgorithmVersion(data.algorithm_version);
const metadata = candidateSegmentMetadata(data);
const candidates = readCandidates(data.candidate_decisions, datedRange, dated).map((candidate) => ({ ...candidate, ...metadata.byTime.get(candidate.time) }));
const receipt = readDecisionReceipt(data.decision_receipt, candidates);
@@ -1071,7 +1071,7 @@ export async function runV9Diagnostics(input: {
// Keep the receipt default aligned with Python's ALGORITHM_VERSION; persisted
// receipts retain their original identity (this is not a Skill/open gate).
export const v9EngineVersion = (): string =>
process.env.RECTIFICATION_ENGINE_VERSION?.trim() || "rectification-v5-matrix-scoring-9";
process.env.RECTIFICATION_ENGINE_VERSION?.trim() || "rectification-v5-matrix-scoring-10";
export type V9RangeReading = Readonly<{
stableThemes: readonly string[];
@@ -6,6 +6,7 @@
*/
import { shouldForceMinuteAfterSubBlocks } from "./block-scan.ts";
import { segmentOrderEnabledFor } from "../core/segment-probe-order.ts";
import { isDatedScoringAlgorithmVersion } from "../core/candidate-window.ts";
import { scanCaseSegments } from "./segment-scan.ts";
import { targetChartsForDomain } from "../core/segment-summary.ts";
import { buildProductCaseInferenceState } from "./product-inference.ts";
@@ -178,7 +179,7 @@ export async function scoreAndPersistCurrentEvidence(input: {
if (!candidateRange) throw new RectificationToolServiceError("case_range_missing");
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
const liveIdentity = await readV9EngineScoringIdentity();
if (scoringIdentityIsTrusted(liveIdentity) && liveIdentity.algorithmVersion === "rectification-v5-matrix-scoring-9" && !candidateRange.candidate_intervals) {
if (scoringIdentityIsTrusted(liveIdentity) && isDatedScoringAlgorithmVersion(liveIdentity.algorithmVersion) && !candidateRange.candidate_intervals) {
candidateRange = await ensureV9DatedWindow(input.accounting, input.userId, input.caseId);
}
if (candidateRange.midnight_side_pending === true && scoringIdentityIsTrusted(liveIdentity)) {
@@ -12,7 +12,7 @@
import { createHash } from "node:crypto";
import { segmentCandidateFields } from "./segment-contract.ts";
import { parseSavedCivilFields } from "./saved-civil-fields.ts";
import { candidatePositionFields, readCandidatePosition, parseCandidateIntervals, type CandidatePosition, type DatedCandidateRange } from "../core/candidate-window.ts";
import { candidatePositionFields, isDatedScoringAlgorithmVersion, readCandidatePosition, parseCandidateIntervals, type CandidatePosition, type DatedCandidateRange } from "../core/candidate-window.ts";
import { assertV9ResultWritable, assertV9WindowWritable } from "./result-identity.ts";
import type { AdoptedCredibleRangePayload } from "../../report-candidate-range.ts";
import {
@@ -530,7 +530,7 @@ export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot |
|| tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
) return null;
const position = readCandidatePosition(candidate);
if ((row.algorithm_version === "rectification-v5-matrix-scoring-9" || candidate.candidate_date !== undefined) && !position) return null;
if ((isDatedScoringAlgorithmVersion(row.algorithm_version) || candidate.candidate_date !== undefined) && !position) return null;
seenIds.add(candidateId);
candidates.push({
candidateId,
@@ -633,7 +633,7 @@ export async function loadV9CaseDossier(
? { resultId: latest.resultId, candidateId, date, time, contract: "segment-v1" as const } : null;
// The old confirmation SQL uses the persisted baseline date for a wholly legacy contract.
// Do not infer that civil date from the scan, current profile or a matching clock.
const legacy = segmentSelection && latest.algorithmVersion !== "rectification-v5-matrix-scoring-9"
const legacy = segmentSelection && !isDatedScoringAlgorithmVersion(latest.algorithmVersion)
&& !Object.hasOwn(latest.decisionReceipt ?? {}, "candidate_window_contract")
&& !Object.hasOwn(latest.decisionReceipt ?? {}, "candidate_intervals")
&& latest.candidates.every(candidate => candidate.candidate_date === undefined)
@@ -1,4 +1,4 @@
import { readCandidatePosition, parseWindowIntervals, type CandidatePosition, type WindowInterval } from "./rectification-agentic/core/candidate-window.ts";
import { isDatedScoringAlgorithmVersion, readCandidatePosition, parseWindowIntervals, type CandidatePosition, type WindowInterval } from "./rectification-agentic/core/candidate-window.ts";
import type {
DashaAgreement,
EventDashaLedgerRow,
@@ -370,7 +370,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
const clusterStart = time(candidate.clusterStart ?? candidate.cluster_start);
const clusterEnd = time(candidate.clusterEnd ?? candidate.cluster_end);
const position = readCandidatePosition(candidate);
if ((candidate.candidate_date !== undefined || snapshot.algorithmVersion === "rectification-v5-matrix-scoring-9") && !position) return null;
if ((candidate.candidate_date !== undefined || isDatedScoringAlgorithmVersion(snapshot.algorithmVersion)) && !position) return null;
candidates.push({
...(position ?? {}),
...(position?.cluster_intervals ? { clusterIntervals: position.cluster_intervals } : {}),