diff --git a/apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts b/apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts index 8da9beb..c6e4cff 100644 --- a/apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts +++ b/apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts @@ -10,6 +10,7 @@ import type { const RESULT_ID = /^m48-small-static-passage-regression-[a-f0-9]{64}$/; const PACK_ID = /^m48-object-quality-pack-[a-f0-9]{64}$/; +const REFERENCE_GRAPH_LAB_RESULT_ID = /^m47-reference-graph-lab-[a-f0-9]{64}$/; const ANCHOR_ID = /^anchor-[a-f0-9]{24}$/; const SAFE_URL = /^\/api\/v1\/laboratory\/m48\/[A-Za-z0-9/_?&=.%:-]+$/; @@ -28,6 +29,7 @@ export interface M48SmallStaticRegressionMetrics { export interface M48SmallStaticRegressionResult { resultId: string; packId: string; + referenceGraphLabResultId: string; createdAtUtc: string; runLabel: "M4.8R1"; pipelineId: "m48-class-free-object-quality/v1"; @@ -53,6 +55,7 @@ export interface M48SmallStaticRegressionCaseSummary { anchorId: string; clipId: string; sequence: number; + anchorExtentXyxy: readonly [number, number, number, number]; requiresAvoidanceOrClearance: boolean; workerCandidateCount: number; bestIou: number; @@ -198,6 +201,7 @@ function parseSummary(value: unknown): M48SmallStaticRegressionCaseSummary { anchorId, clipId: textValue(row.clip_id, "M4.8R1.case.clip_id"), sequence: integerValue(row.sequence, "M4.8R1.case.sequence"), + anchorExtentXyxy: extentValue(row.anchor_extent_xyxy, "M4.8R1.case.anchor_extent_xyxy"), requiresAvoidanceOrClearance: booleanValue(row.requires_avoidance_or_clearance, "M4.8R1.case.requires_avoidance_or_clearance"), workerCandidateCount: integerValue(row.worker_candidate_count, "M4.8R1.case.worker_candidate_count"), bestIou: numberValue(row.best_iou, "M4.8R1.case.best_iou"), @@ -223,6 +227,15 @@ export async function fetchM48SmallStaticRegression( exact(payload.schema_version, "missioncore.m48-small-static-passage-regression-result-view/v1", "M4.8R1.schema_version"); const packId = textValue(payload.pack_id, "M4.8R1.pack_id"); if (!PACK_ID.test(packId)) throw new M48SmallStaticRegressionContractError("M4.8R1.pack_id: нарушена идентичность."); + const referenceGraphLabResultId = textValue( + payload.reference_graph_lab_result_id, + "M4.8R1.reference_graph_lab_result_id", + ); + if (!REFERENCE_GRAPH_LAB_RESULT_ID.test(referenceGraphLabResultId)) { + throw new M48SmallStaticRegressionContractError( + "M4.8R1.reference_graph_lab_result_id: нарушена идентичность.", + ); + } const metrics = objectValue(payload.metrics, "M4.8R1.metrics"); const gates = objectValue(payload.gates, "M4.8R1.gates"); const decision = objectValue(payload.decision, "M4.8R1.decision"); @@ -235,6 +248,7 @@ export async function fetchM48SmallStaticRegression( return { resultId, packId, + referenceGraphLabResultId, createdAtUtc: textValue(payload.created_at_utc, "M4.8R1.created_at_utc"), runLabel: "M4.8R1", pipelineId: "m48-class-free-object-quality/v1", diff --git a/src/k1link/web/m48_object_quality_api.py b/src/k1link/web/m48_object_quality_api.py index ae7e3b6..75253c7 100644 --- a/src/k1link/web/m48_object_quality_api.py +++ b/src/k1link/web/m48_object_quality_api.py @@ -57,6 +57,7 @@ _SMALL_STATIC_RESULT_ID = re.compile( r"^m48-small-static-passage-regression-[a-f0-9]{64}$" ) _SMALL_STATIC_ANCHOR_ID = re.compile(r"^anchor-[a-f0-9]{24}$") +_M47_LAB_RESULT_ID = re.compile(r"^m47-reference-graph-lab-[a-f0-9]{64}$") _FAILURE_ID = re.compile(r"^m48-failure-[a-f0-9]{64}$") _REVIEW_SESSION_ID = re.compile(r"^m48-review-session-[a-f0-9]{64}$") _CORRECTION_SESSION_ID = re.compile(r"^m48-correction-session-[a-f0-9]{64}$") @@ -1478,6 +1479,17 @@ def build_m48_object_quality_router( metrics = _object(result.report.get("metrics"), "M4.8 regression metrics") gates = _object(result.report.get("gates"), "M4.8 regression gates") decision = _object(result.report.get("decision"), "M4.8 regression decision") + pack_identity = _object(pack.manifest.get("identity"), "M4.8 pack identity") + pack_source = _object(pack_identity.get("source"), "M4.8 pack source") + reference_graph_lab_result_id = pack_source.get("m47_lab_result_id") + if ( + not isinstance(reference_graph_lab_result_id, str) + or _M47_LAB_RESULT_ID.fullmatch(reference_graph_lab_result_id) is None + ): + raise HTTPException( + status_code=409, + detail="M4.8 regression lost its canonical reference graph binding", + ) return { "schema_version": _SMALL_STATIC_RESULT_VIEW_SCHEMA, "result_id": result.result_id, @@ -1486,6 +1498,7 @@ def build_m48_object_quality_router( "run_label": result.manifest["identity"].get("run_label"), "pipeline_id": result.manifest["identity"].get("pipeline_id"), "experiment_id": result.manifest["identity"].get("experiment_id"), + "reference_graph_lab_result_id": reference_graph_lab_result_id, "accepted": result.manifest.get("accepted"), "metrics": copy.deepcopy(metrics), "gates": copy.deepcopy(gates), @@ -1513,6 +1526,7 @@ def build_m48_object_quality_router( "anchor_id": row["anchor_id"], "clip_id": row["clip_id"], "sequence": row["sequence"], + "anchor_extent_xyxy": copy.deepcopy(row["anchor_extent_xyxy"]), "requires_avoidance_or_clearance": row[ "requires_avoidance_or_clearance" ], diff --git a/tests/test_m48_object_quality_api.py b/tests/test_m48_object_quality_api.py index 00b45a3..a5a2d70 100644 --- a/tests/test_m48_object_quality_api.py +++ b/tests/test_m48_object_quality_api.py @@ -92,7 +92,10 @@ def _fixture( "identity_sha256": "a" * 64, "created_at_utc": "2026-08-23T20:00:00Z", "identity": { - "source": {"source_session_id": "recorded-session"}, + "source": { + "source_session_id": "recorded-session", + "m47_lab_result_id": f"m47-reference-graph-lab-{'c' * 64}", + }, "freeze": {"prediction_rows_sha256": "8" * 64}, }, }, @@ -1101,6 +1104,9 @@ def test_m48_small_static_regression_is_separate_read_only_assisted_evidence( assert summary.json()["experiment_id"] == ( "m48-small-static-passage-regression/v1" ) + assert summary.json()["reference_graph_lab_result_id"] == ( + f"m47-reference-graph-lab-{'c' * 64}" + ) assert summary.json()["independent_truth"] is False assert summary.json()["metrics"]["worker_missed_anchor_count"] == 1 @@ -1110,6 +1116,7 @@ def test_m48_small_static_regression_is_separate_read_only_assisted_evidence( assert catalog.status_code == 200 assert catalog.json()["case_count"] == 1 assert catalog.json()["cases"][0]["outcome"] == "missed-assisted-anchor" + assert catalog.json()["cases"][0]["anchor_extent_xyxy"] == [0.2, 0.2, 0.3, 0.4] case = client.get( f"/api/v1/laboratory/m48/regressions/small-static/{result_id}/cases/{anchor_id}"