From c48a96564060b78e4ea5c0cef394c50d6eaa8d47 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 18:17:33 +0800 Subject: [PATCH] fix(rectification): close round A/2 tail gaps in tests, CI, and stale-score reuse 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 --- .../rectification-agentic/v9/engine-client.ts | 87 ++++++++++++++++++- frontend/src/mastra/rectification-v9-tools.ts | 10 ++- .../rectification-engine-convergence.test.ts | 37 ++++++++ .../rectification-v9-status-security.test.ts | 71 ++++++++++++++- scripts/jyotish_api_server.py | 17 ++++ scripts/rectification/api_service.py | 9 ++ scripts/run_quality_gate.py | 4 + ...test_rectification_diagnostics_clusters.py | 7 ++ tests/test_rectification_v5_services.py | 15 ++++ 9 files changed, 250 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index c0a045a3..cad8edff 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -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> { +async function readEngineJson(path: string, init: RequestInit, timeoutMs: number): Promise> { 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; } +async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Promise> { + return readEngineJson(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, timeoutMs); +} + +async function getEngine(path: string, timeoutMs = 5_000): Promise> { + 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 = 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> | 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 { + 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 }, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index f5cbfdc9..d1411cc8 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -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); diff --git a/frontend/tests/rectification-engine-convergence.test.ts b/frontend/tests/rectification-engine-convergence.test.ts index 0f971fb7..91fe2329 100644 --- a/frontend/tests/rectification-engine-convergence.test.ts +++ b/frontend/tests/rectification-engine-convergence.test.ts @@ -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); diff --git a/frontend/tests/rectification-v9-status-security.test.ts b/frontend/tests/rectification-v9-status-security.test.ts index f450315a..31bb3d9e 100644 --- a/frontend/tests/rectification-v9-status-security.test.ts +++ b/frontend/tests/rectification-v9-status-security.test.ts @@ -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; + }).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, diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 6bb1bb7d..9f6849ba 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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']}, diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index ca1efeff..9e59b44c 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -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) diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 4debe1f3..95652cb7 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -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 = [ diff --git a/tests/test_rectification_diagnostics_clusters.py b/tests/test_rectification_diagnostics_clusters.py index d724f79e..c6a32e19 100644 --- a/tests/test_rectification_diagnostics_clusters.py +++ b/tests/test_rectification_diagnostics_clusters.py @@ -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() diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index df461292..a5dcdef2 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -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"] = []