fix(rectification): close round A/2 tail gaps in tests, CI, and stale-score reuse
Independent Staging Quality Gate / validate (pull_request) Failing after 6m13s
Independent Staging Quality Gate / publish (pull_request) Has been skipped

Window_scan assertions now match the public from_sign/to_sign contract, the
staging quick gate runs the rectification Python suite, and compare-candidates
rescores when stored policy lags the live engine identity.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-01 18:17:33 +08:00
parent 26ea3f06cb
commit c48a965640
9 changed files with 250 additions and 7 deletions
@@ -181,11 +181,9 @@ function engineBase(): string {
return process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200";
}
async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Promise<Record<string, unknown>> {
async function readEngineJson(path: string, init: RequestInit, timeoutMs: number): Promise<Record<string, unknown>> {
const response = await fetch(`${engineBase()}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
...init,
signal: AbortSignal.timeout(timeoutMs),
});
const data = await response.json().catch(() => null);
@@ -201,6 +199,87 @@ async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Prom
return data as Record<string, unknown>;
}
async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Promise<Record<string, unknown>> {
return readEngineJson(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}, timeoutMs);
}
async function getEngine(path: string, timeoutMs = 5_000): Promise<Record<string, unknown>> {
return readEngineJson(path, {
method: "GET",
headers: { accept: "application/json" },
}, timeoutMs);
}
/** Coarse product id is not a scoring identity; never treat it as live algorithm. */
const COARSE_ENGINE_PRODUCT_ID = "rectification-v5";
export type LiveEngineScoringIdentity = Readonly<{
algorithmVersion: string | null;
policyVersion: string | null;
}>;
export function liveEngineScoringIdentityFromEnv(
env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env,
): LiveEngineScoringIdentity {
const engine = env.RECTIFICATION_ENGINE_VERSION?.trim() || "";
const algorithm = env.RECTIFICATION_ALGORITHM_VERSION?.trim()
|| (engine && engine !== COARSE_ENGINE_PRODUCT_ID ? engine : "");
const policy = env.RECTIFICATION_DECISION_POLICY_VERSION?.trim() || "";
return {
algorithmVersion: algorithm || null,
policyVersion: policy || null,
};
}
export function scoringIdentityFromEnginePayload(
payload: Readonly<Record<string, unknown>> | null | undefined,
): LiveEngineScoringIdentity {
const algorithm = typeof payload?.algorithm_version === "string" ? payload.algorithm_version.trim() : "";
const policy = typeof payload?.decision_policy_version === "string"
? payload.decision_policy_version.trim()
: "";
return {
algorithmVersion: algorithm || null,
policyVersion: policy || null,
};
}
export function cachedEngineScoreIsReusable(
stored: {
evidenceLedgerFingerprint?: string | null;
candidateRangeFingerprint?: string | null;
algorithmVersion?: string | null;
policyVersion?: string | null;
} | null | undefined,
fingerprints: { evidenceLedgerFingerprint: string; candidateRangeFingerprint: string },
live: LiveEngineScoringIdentity,
): boolean {
if (!stored) return false;
if (stored.evidenceLedgerFingerprint !== fingerprints.evidenceLedgerFingerprint) return false;
if (stored.candidateRangeFingerprint !== fingerprints.candidateRangeFingerprint) return false;
if (live.policyVersion && stored.policyVersion && stored.policyVersion !== live.policyVersion) {
return false;
}
if (live.algorithmVersion && stored.algorithmVersion && stored.algorithmVersion !== live.algorithmVersion) {
return false;
}
return true;
}
export async function readV9EngineScoringIdentity(): Promise<LiveEngineScoringIdentity> {
const fromEnv = liveEngineScoringIdentityFromEnv();
if (fromEnv.policyVersion || fromEnv.algorithmVersion) return fromEnv;
try {
return scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions"));
} catch {
return { algorithmVersion: null, policyVersion: null };
}
}
function readCandidates(
value: unknown,
range: { start_time: string; end_time: string },
@@ -132,6 +132,8 @@ import {
runV9CandidateScore,
runV9Diagnostics,
runV9VedastroValidate,
readV9EngineScoringIdentity,
cachedEngineScoreIsReusable,
toEngineEvents,
v9EngineVersion,
executedMethodsFromLedger,
@@ -830,10 +832,14 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
);
const events = toEngineEvents(scorableEvidence(dossier.evidence));
const latest = dossier.latestResult;
const liveIdentity = await readV9EngineScoringIdentity();
if (
latest
&& latest.evidenceLedgerFingerprint === evidenceFingerprint
&& latest.candidateRangeFingerprint === rangeFingerprint
&& cachedEngineScoreIsReusable(
latest,
{ evidenceLedgerFingerprint: evidenceFingerprint, candidateRangeFingerprint: rangeFingerprint },
liveIdentity,
)
) {
const ledger = latest.executionLedger ?? [];
const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt);
@@ -11,6 +11,10 @@ import {
} from "../src/lib/rectification-agentic/core/snapshot-source.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import { contrastPacketFromLatestResult } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
cachedEngineScoreIsReusable,
liveEngineScoringIdentityFromEnv,
} from "../src/lib/rectification-agentic/v9/engine-client.ts";
import {
parseDiscriminatingEventProbes,
parseProspectiveProbes,
@@ -84,6 +88,39 @@ test("scoring policy version changes stale stored snapshots", () => {
assert.equal(storedSnapshotIsCurrent(current, current), true);
});
test("matching fingerprints still rescore when stored policy lags the live engine", () => {
const fingerprints = {
evidenceLedgerFingerprint: "b".repeat(64),
candidateRangeFingerprint: "c".repeat(64),
};
const stored = {
...fingerprints,
algorithmVersion: "rectification-v5-matrix-scoring-6",
policyVersion: "rectification-candidate-policy-v2",
};
assert.equal(
cachedEngineScoreIsReusable(stored, fingerprints, {
algorithmVersion: "rectification-v5-matrix-scoring-6",
policyVersion: "rectification-candidate-policy-v2",
}),
true,
);
assert.equal(
cachedEngineScoreIsReusable(stored, fingerprints, {
algorithmVersion: "rectification-v5-matrix-scoring-7",
policyVersion: "rectification-candidate-policy-v3",
}),
false,
);
const fromEnv = liveEngineScoringIdentityFromEnv({
RECTIFICATION_ENGINE_VERSION: "rectification-v5",
RECTIFICATION_DECISION_POLICY_VERSION: "rectification-candidate-policy-v3",
});
assert.equal(fromEnv.algorithmVersion, null);
assert.equal(fromEnv.policyVersion, "rectification-candidate-policy-v3");
assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, fromEnv), false);
});
test("anchored known_event_quality distinguish probes stay in the public packet", () => {
const parsed = parseDiscriminatingEventProbes([qualityProbe()]);
assert.equal(parsed.length, 1);
@@ -692,7 +692,17 @@ test("compare-candidates reuses a matching fingerprint without calling the engin
const evidenceFp = evidenceLedgerFingerprint(parsed.evidence);
const rangeFp = candidateRangeFingerprint(parsed.case.candidateRange, compute.baselineProfileFingerprint);
const previous = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/versions")) {
return {
ok: true,
json: async () => ({
algorithm_version: "rectification-v5",
decision_policy_version: "rectification-candidate-policy-v2",
}),
} as Response;
}
throw new Error("engine must not run on a matching fingerprint");
}) as unknown as typeof fetch;
try {
@@ -729,6 +739,65 @@ test("compare-candidates reuses a matching fingerprint without calling the engin
}
});
test("compare-candidates rescores when stored policy lags the live engine", async () => {
const rawDossier = dossierFixture();
const parsed = parseV9CaseDossier(rawDossier);
const compute = parseV9ComputeProjection(computeFixture());
assert.ok(parsed);
assert.ok(compute);
assert.ok(parsed.case.candidateRange);
const evidenceFp = evidenceLedgerFingerprint(parsed.evidence);
const rangeFp = candidateRangeFingerprint(parsed.case.candidateRange, compute.baselineProfileFingerprint);
const previous = globalThis.fetch;
let scoreRequested = false;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/versions")) {
return {
ok: true,
json: async () => ({
algorithm_version: "rectification-v5-matrix-scoring-7",
decision_policy_version: "rectification-candidate-policy-v3",
}),
} as Response;
}
if (url.includes("/api/rectification/v5/score")) {
scoreRequested = true;
}
throw new Error("stale policy must rescore");
}) as unknown as typeof fetch;
try {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
latestResult: {
...candidateSnapshotFixture({ evidenceLedgerFingerprint: evidenceFp }),
candidate_range_fingerprint: rangeFp,
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: () => {
throw new Error("persist must not run until the live engine scores");
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
(tools["rectification-compare-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID }),
(error: unknown) => error instanceof Error && error.message.includes("stale policy must rescore"),
);
assert.equal(scoreRequested, true);
} finally {
globalThis.fetch = previous;
}
});
test("compare-candidates refuses to run without scorable evidence", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
+17
View File
@@ -3019,6 +3019,7 @@ API_COMMAND_MAP = {
'rectification-v5-score': '/api/rectification/v5/score',
'rectification-v5-diagnostics': '/api/rectification/v5/diagnostics',
'rectification-v5-vedastro-validate': '/api/rectification/v5/vedastro-validate',
'rectification-v5-versions': '/api/rectification/v5/versions',
'case-validation': '/api/case_validation',
'divisional-yoga': '/api/divisional_yoga',
'deep-varga-avastha': '/api/deep_varga_avastha',
@@ -3058,6 +3059,7 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
'/api/rectification/v5/score',
'/api/rectification/v5/diagnostics',
'/api/rectification/v5/vedastro-validate',
'/api/rectification/v5/versions',
'/api/relationship',
'/api/remedies',
'/api/sade_sati',
@@ -3293,6 +3295,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'async_job_runtime': async_job_runtime_status(),
'vedastro': self._vedastro_status(),
})
elif path == '/api/rectification/v5/versions':
self._json(self._compute_rectification_v5_versions())
elif path == '/api/cities':
self._json(list(CITY_DB.keys()))
elif path == '/api/capability_audit':
@@ -3518,6 +3522,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
self._json(self._compute_rectification_v5_diagnostics(body))
elif path == '/api/rectification/v5/vedastro-validate':
self._json(self._compute_rectification_v5_vedastro_validate(body))
elif path == '/api/rectification/v5/versions':
self._json(self._compute_rectification_v5_versions())
elif path == '/api/dynamic_rectification_opportunities':
result = self._compute_dynamic_rectification_opportunities(body)
self._json(result)
@@ -9061,6 +9067,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
except ValueError as exc:
raise BadRequest(str(exc)) from exc
def _compute_rectification_v5_versions(self, body=None):
from scripts.rectification.api_service import engine_scoring_versions
return {
'success': True,
'endpoint': 'rectification_v5_versions',
**engine_scoring_versions(),
}
def _compute_rectification_v5_candidate_features(self, body):
from scripts.rectification.api_service import candidate_features
return {
@@ -10111,6 +10125,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/rectification/v5/score': self._compute_rectification_v5_score,
'/api/rectification/v5/diagnostics': self._compute_rectification_v5_diagnostics,
'/api/rectification/v5/vedastro-validate': self._compute_rectification_v5_vedastro_validate,
'/api/rectification/v5/versions': self._compute_rectification_v5_versions,
'/api/relationship': self._compute_relationship,
'/api/remedies': self._compute_remedies,
'/api/sade_sati': self._compute_sade_sati,
@@ -10241,6 +10256,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/rectification/v5/score': 'Build the V5 event-by-candidate contribution matrix and score candidate ranges',
'/api/rectification/v5/diagnostics': 'Run V5 stability diagnostics over the server-owned contribution matrix',
'/api/rectification/v5/vedastro-validate': 'Validate one V5 primary/runner-up pair with safe official VedAstro summaries',
'/api/rectification/v5/versions': 'Return live V5 algorithm and decision-policy identity without scoring',
'/api/relationship': 'Compute relationship and spouse-status evidence',
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
'/api/sade_sati': 'Compute Sade Sati status and phase',
@@ -10324,6 +10340,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'candidate_times': ['05:01', '05:02'],
'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}],
},
'/api/rectification/v5/versions': {},
'/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}},
'/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'},
'/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']},
+9
View File
@@ -152,6 +152,15 @@ def _rectification_report(
}
def engine_scoring_versions() -> dict[str, str]:
"""Identity fields also returned by `/api/rectification/v5/score`, without scoring."""
return {
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
"decision_policy_version": POLICY_VERSION,
}
def candidate_features(request: RectificationRequest) -> dict[str, Any]:
spec = calculation_spec(request)
spec_hash = sha256(spec)
+4
View File
@@ -59,6 +59,10 @@ CORE_PYTEST_TARGETS = [
# Staging quick profile never runs `tests/` wholesale. A distinguish probe
# with empty mapping or non-positive gain must fail this gate (BUG-393).
"tests/test_candidate_discriminator_contract.py",
# Auto staging gate is `--profile quick`. `test.yml` / `ci.yml` run the
# full pytest tree but are workflow_dispatch only, so a stale window_scan
# assertion in this glob stayed red on origin/staging until listed here.
"tests/test_rectification_*.py",
]
RUNTIME_TRUTH_PYTEST_TARGETS = [
@@ -120,6 +120,8 @@ class RectificationDiagnosticsClustersTest(unittest.TestCase):
"layer": "d9",
"at": "05:14",
"user_meaning": "D9 在 05:14 发生变化",
"from_sign": "金牛座",
"to_sign": "天蝎座",
}])
encoded = str(scan)
self.assertIn("金牛座", encoded)
@@ -128,6 +130,11 @@ class RectificationDiagnosticsClustersTest(unittest.TestCase):
self.assertNotIn("Scorpio", encoded)
self.assertFalse(scan["unique_minute_claim"])
def test_staging_quick_gate_runs_rectification_python_suite(self) -> None:
from pathlib import Path
text = Path("scripts/run_quality_gate.py").read_text(encoding="utf-8")
self.assertIn('"tests/test_rectification_*.py"', text)
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -675,11 +675,26 @@ class RectificationV5ServicesTest(unittest.TestCase):
"rectification-v5-candidate-features": "/api/rectification/v5/candidate-features",
"rectification-v5-score": "/api/rectification/v5/score",
"rectification-v5-diagnostics": "/api/rectification/v5/diagnostics",
"rectification-v5-versions": "/api/rectification/v5/versions",
}
for command, endpoint in expected.items():
self.assertEqual(API_COMMAND_MAP[command], endpoint)
self.assertIn(endpoint, TECHNIQUE_EXAMPLE_ENDPOINTS)
def test_engine_scoring_versions_match_score_identity(self):
from scripts.rectification.api_service import engine_scoring_versions
from scripts.rectification.decision_policy import POLICY_VERSION
from scripts.rectification.scoring_service import ALGORITHM_VERSION
versions = engine_scoring_versions()
self.assertEqual(versions["algorithm_version"], ALGORITHM_VERSION)
self.assertEqual(versions["decision_policy_version"], POLICY_VERSION)
handler = object.__new__(JyotishAPIHandler)
payload = handler._compute_rectification_v5_versions()
self.assertEqual(payload["algorithm_version"], ALGORITHM_VERSION)
self.assertEqual(payload["decision_policy_version"], POLICY_VERSION)
self.assertEqual(payload["endpoint"], "rectification_v5_versions")
def test_zero_evidence_returns_declared_window_report(self):
body = request(start_time="23:58", end_time="00:02")
body["events"] = []