From a88743f20c7567134f788cb3f4832e3328697ee0 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 10 Jul 2026 11:48:44 +0700 Subject: [PATCH] feat: Phase I I2 JSON-LD context for evidence certificates -- JLC- prefix, OpenAMP namespace, annotate_certificate_with_context, 63 tests (#911) --- src/openamp_foundry/interop/jsonld_context.py | 180 ++++++++++ tests/interop/__init__.py | 0 tests/interop/test_jsonld_context.py | 321 ++++++++++++++++++ 3 files changed, 501 insertions(+) create mode 100644 src/openamp_foundry/interop/jsonld_context.py create mode 100644 tests/interop/__init__.py create mode 100644 tests/interop/test_jsonld_context.py diff --git a/src/openamp_foundry/interop/jsonld_context.py b/src/openamp_foundry/interop/jsonld_context.py new file mode 100644 index 00000000..6ae5fac0 --- /dev/null +++ b/src/openamp_foundry/interop/jsonld_context.py @@ -0,0 +1,180 @@ +"""JSON-LD context generation for OpenAMP evidence certificates.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +JSONLD_CONTEXT_ID_PREFIX = "JLC-" + +JSONLD_CONTEXT_VERSION = "1.0" + +OPENAMP_NAMESPACE = "https://openamp-foundry.org/ns/v1#" + +SCHEMA_ORG_NAMESPACE = "https://schema.org/" + +VALID_EVIDENCE_TYPES: frozenset = frozenset( + { + "EvidenceCertificate", + "BatchOutcomeSummary", + "NegativeResultRecord", + "BenchmarkCard", + "ExternalReviewPacket", + "ReleaseManifest", + "CalibrationCycleSummary", + } +) + +VALID_PROOF_LADDER_LEVELS: frozenset = frozenset( + { + "dry_lab_candidate", + "multi_signal_candidate_evidence", + "expert_reviewed_assay_proposal", + "validated_assay_result", + "independently_replicated", + } +) + +REQUIRED_CERTIFICATE_FIELDS: frozenset = frozenset( + { + "certificate_id", + "candidate_id", + "proof_ladder_level", + "dry_lab_only", + "created_at", + } +) + +JSONLD_BASE_CONTEXT: dict = { + "@vocab": OPENAMP_NAMESPACE, + "schema": SCHEMA_ORG_NAMESPACE, + "xsd": "http://www.w3.org/2001/XMLSchema#", + "certificate_id": {"@id": "openamp:certificateId"}, + "candidate_id": {"@id": "openamp:candidateId"}, + "proof_ladder_level": {"@id": "openamp:proofLadderLevel"}, + "dry_lab_only": {"@id": "openamp:dryLabOnly", "@type": "xsd:boolean"}, + "created_at": {"@id": "schema:dateCreated"}, + "pipeline_version": {"@id": "openamp:pipelineVersion"}, + "run_id": {"@id": "openamp:runId"}, + "unsupported_claims": {"@id": "openamp:unsupportedClaims"}, + "baseline_caveat": {"@id": "openamp:baselineCaveat"}, + "selection_rationale": {"@id": "openamp:selectionRationale"}, + "failure_modes": {"@id": "openamp:failureModes"}, + "human_review_required": { + "@id": "openamp:humanReviewRequired", + "@type": "xsd:boolean", + }, + "release_status": {"@id": "openamp:releaseStatus"}, + "batch_id": {"@id": "openamp:batchId"}, + "evidence_type": {"@id": "@type"}, +} + + +@dataclass +class JsonLdContextRecord: + context_id: str + context_version: str + evidence_type: str + context_document: dict = field(default_factory=dict) + namespace: str = OPENAMP_NAMESPACE + is_dry_lab_constrained: bool = True + generation_note: str = "" + + +@dataclass +class JsonLdContextValidationResult: + is_valid: bool + violations: list = field(default_factory=list) + + +def build_jsonld_context_record( + context_id: str, + evidence_type: str, + context_version: str = JSONLD_CONTEXT_VERSION, + extra_terms: dict | None = None, + generation_note: str = "", +) -> JsonLdContextRecord: + context_doc = dict(JSONLD_BASE_CONTEXT) + if extra_terms: + context_doc.update(extra_terms) + return JsonLdContextRecord( + context_id=context_id, + context_version=context_version, + evidence_type=evidence_type, + context_document=context_doc, + namespace=OPENAMP_NAMESPACE, + is_dry_lab_constrained=True, + generation_note=generation_note or f"Auto-generated context for {evidence_type}", + ) + + +def validate_jsonld_context_record( + record: JsonLdContextRecord, +) -> JsonLdContextValidationResult: + violations: list[str] = [] + + if not record.context_id.startswith(JSONLD_CONTEXT_ID_PREFIX): + violations.append( + f"context_id must start with '{JSONLD_CONTEXT_ID_PREFIX}', got '{record.context_id}'" + ) + + if record.evidence_type not in VALID_EVIDENCE_TYPES: + violations.append( + f"evidence_type '{record.evidence_type}' not in VALID_EVIDENCE_TYPES" + ) + + if not record.context_version: + violations.append("context_version must not be empty") + + if "@vocab" not in record.context_document: + violations.append("context_document must contain '@vocab' key") + + if record.namespace != OPENAMP_NAMESPACE: + violations.append( + f"namespace must be '{OPENAMP_NAMESPACE}', got '{record.namespace}'" + ) + + missing_required = REQUIRED_CERTIFICATE_FIELDS - set(record.context_document.keys()) + if missing_required: + violations.append( + f"context_document missing required certificate fields: {sorted(missing_required)}" + ) + + if not record.is_dry_lab_constrained: + violations.append( + "is_dry_lab_constrained must be True; wet-lab contexts require human review" + ) + + if not record.generation_note: + violations.append("generation_note must not be empty") + + return JsonLdContextValidationResult( + is_valid=len(violations) == 0, + violations=violations, + ) + + +def format_jsonld_context_record(record: JsonLdContextRecord) -> str: + import json + + doc = { + "@context": record.context_document, + "_meta": { + "context_id": record.context_id, + "context_version": record.context_version, + "evidence_type": record.evidence_type, + "namespace": record.namespace, + "is_dry_lab_constrained": record.is_dry_lab_constrained, + "generation_note": record.generation_note, + }, + } + return json.dumps(doc, indent=2, sort_keys=False) + + +def annotate_certificate_with_context( + certificate_dict: dict, + context_record: JsonLdContextRecord, +) -> dict: + annotated = dict(certificate_dict) + annotated["@context"] = context_record.context_document + annotated["@type"] = context_record.evidence_type + return annotated diff --git a/tests/interop/__init__.py b/tests/interop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/interop/test_jsonld_context.py b/tests/interop/test_jsonld_context.py new file mode 100644 index 00000000..9d5a9db0 --- /dev/null +++ b/tests/interop/test_jsonld_context.py @@ -0,0 +1,321 @@ +"""Tests for src/openamp_foundry/interop/jsonld_context.py (63 tests).""" + +import json +import pytest +from openamp_foundry.interop.jsonld_context import ( + JSONLD_CONTEXT_ID_PREFIX, + JSONLD_CONTEXT_VERSION, + OPENAMP_NAMESPACE, + SCHEMA_ORG_NAMESPACE, + VALID_EVIDENCE_TYPES, + VALID_PROOF_LADDER_LEVELS, + REQUIRED_CERTIFICATE_FIELDS, + JSONLD_BASE_CONTEXT, + JsonLdContextRecord, + JsonLdContextValidationResult, + build_jsonld_context_record, + validate_jsonld_context_record, + format_jsonld_context_record, + annotate_certificate_with_context, +) + + +def _valid_record(evidence_type="EvidenceCertificate"): + return build_jsonld_context_record( + context_id="JLC-001", + evidence_type=evidence_type, + generation_note="Test context", + ) + + +class TestConstants: + def test_prefix(self): + assert JSONLD_CONTEXT_ID_PREFIX == "JLC-" + + def test_context_version(self): + assert JSONLD_CONTEXT_VERSION == "1.0" + + def test_openamp_namespace_starts_with_https(self): + assert OPENAMP_NAMESPACE.startswith("https://") + + def test_schema_org_namespace(self): + assert "schema.org" in SCHEMA_ORG_NAMESPACE + + def test_valid_evidence_types_is_frozenset(self): + assert isinstance(VALID_EVIDENCE_TYPES, frozenset) + + def test_evidence_certificate_in_types(self): + assert "EvidenceCertificate" in VALID_EVIDENCE_TYPES + + def test_batch_outcome_in_types(self): + assert "BatchOutcomeSummary" in VALID_EVIDENCE_TYPES + + def test_negative_result_in_types(self): + assert "NegativeResultRecord" in VALID_EVIDENCE_TYPES + + def test_benchmark_card_in_types(self): + assert "BenchmarkCard" in VALID_EVIDENCE_TYPES + + def test_release_manifest_in_types(self): + assert "ReleaseManifest" in VALID_EVIDENCE_TYPES + + def test_valid_proof_ladder_levels_is_frozenset(self): + assert isinstance(VALID_PROOF_LADDER_LEVELS, frozenset) + + def test_dry_lab_candidate_in_proof_levels(self): + assert "dry_lab_candidate" in VALID_PROOF_LADDER_LEVELS + + def test_required_certificate_fields_is_frozenset(self): + assert isinstance(REQUIRED_CERTIFICATE_FIELDS, frozenset) + + def test_certificate_id_required(self): + assert "certificate_id" in REQUIRED_CERTIFICATE_FIELDS + + def test_proof_ladder_level_required(self): + assert "proof_ladder_level" in REQUIRED_CERTIFICATE_FIELDS + + def test_dry_lab_only_required(self): + assert "dry_lab_only" in REQUIRED_CERTIFICATE_FIELDS + + def test_base_context_is_dict(self): + assert isinstance(JSONLD_BASE_CONTEXT, dict) + + def test_base_context_has_vocab(self): + assert "@vocab" in JSONLD_BASE_CONTEXT + + def test_base_context_has_schema(self): + assert "schema" in JSONLD_BASE_CONTEXT + + def test_base_context_has_certificate_id(self): + assert "certificate_id" in JSONLD_BASE_CONTEXT + + +class TestBuildJsonLdContextRecord: + def test_returns_jsonld_context_record(self): + record = _valid_record() + assert isinstance(record, JsonLdContextRecord) + + def test_context_id_stored(self): + record = _valid_record() + assert record.context_id == "JLC-001" + + def test_evidence_type_stored(self): + record = _valid_record() + assert record.evidence_type == "EvidenceCertificate" + + def test_context_version_default(self): + record = _valid_record() + assert record.context_version == JSONLD_CONTEXT_VERSION + + def test_context_version_custom(self): + record = build_jsonld_context_record( + context_id="JLC-001", + evidence_type="BenchmarkCard", + context_version="2.0", + ) + assert record.context_version == "2.0" + + def test_namespace_set(self): + record = _valid_record() + assert record.namespace == OPENAMP_NAMESPACE + + def test_is_dry_lab_constrained_true(self): + record = _valid_record() + assert record.is_dry_lab_constrained is True + + def test_generation_note_stored(self): + record = build_jsonld_context_record( + context_id="JLC-001", + evidence_type="EvidenceCertificate", + generation_note="Custom note", + ) + assert record.generation_note == "Custom note" + + def test_generation_note_auto_generated_when_empty(self): + record = build_jsonld_context_record( + context_id="JLC-001", + evidence_type="EvidenceCertificate", + ) + assert record.generation_note != "" + + def test_context_document_contains_vocab(self): + record = _valid_record() + assert "@vocab" in record.context_document + + def test_extra_terms_merged(self): + record = build_jsonld_context_record( + context_id="JLC-001", + evidence_type="EvidenceCertificate", + extra_terms={"custom_field": "openamp:customField"}, + ) + assert "custom_field" in record.context_document + + +class TestValidateJsonLdContextRecord: + def test_valid_record_passes(self): + result = validate_jsonld_context_record(_valid_record()) + assert result.is_valid + + def test_returns_validation_result(self): + result = validate_jsonld_context_record(_valid_record()) + assert isinstance(result, JsonLdContextValidationResult) + + def test_no_violations_on_valid(self): + result = validate_jsonld_context_record(_valid_record()) + assert result.violations == [] + + def test_wrong_id_prefix_fails(self): + record = _valid_record() + record.context_id = "CTX-001" + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("JLC-" in v for v in result.violations) + + def test_invalid_evidence_type_fails(self): + record = _valid_record() + record.evidence_type = "UnknownType" + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("evidence_type" in v for v in result.violations) + + def test_empty_context_version_fails(self): + record = _valid_record() + record.context_version = "" + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("context_version" in v for v in result.violations) + + def test_missing_vocab_fails(self): + record = _valid_record() + del record.context_document["@vocab"] + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("@vocab" in v for v in result.violations) + + def test_wrong_namespace_fails(self): + record = _valid_record() + record.namespace = "https://wrong.org/ns#" + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("namespace" in v for v in result.violations) + + def test_missing_required_fields_fails(self): + record = _valid_record() + del record.context_document["certificate_id"] + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("missing required" in v for v in result.violations) + + def test_dry_lab_constrained_false_fails(self): + record = _valid_record() + record.is_dry_lab_constrained = False + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("dry_lab_constrained" in v for v in result.violations) + + def test_empty_generation_note_fails(self): + record = _valid_record() + record.generation_note = "" + result = validate_jsonld_context_record(record) + assert not result.is_valid + assert any("generation_note" in v for v in result.violations) + + def test_multiple_violations_accumulated(self): + record = _valid_record() + record.context_id = "BAD-001" + record.evidence_type = "Unknown" + result = validate_jsonld_context_record(record) + assert len(result.violations) >= 2 + + def test_all_valid_evidence_types_accepted(self): + for et in VALID_EVIDENCE_TYPES: + record = build_jsonld_context_record( + context_id="JLC-001", + evidence_type=et, + generation_note="test", + ) + result = validate_jsonld_context_record(record) + assert result.is_valid, f"Type {et} failed: {result.violations}" + + +class TestFormatJsonLdContextRecord: + def test_returns_string(self): + record = _valid_record() + result = format_jsonld_context_record(record) + assert isinstance(result, str) + + def test_valid_json(self): + record = _valid_record() + result = format_jsonld_context_record(record) + parsed = json.loads(result) + assert parsed is not None + + def test_contains_context_key(self): + record = _valid_record() + parsed = json.loads(format_jsonld_context_record(record)) + assert "@context" in parsed + + def test_contains_meta_key(self): + record = _valid_record() + parsed = json.loads(format_jsonld_context_record(record)) + assert "_meta" in parsed + + def test_meta_contains_context_id(self): + record = _valid_record() + parsed = json.loads(format_jsonld_context_record(record)) + assert parsed["_meta"]["context_id"] == "JLC-001" + + def test_meta_contains_evidence_type(self): + record = _valid_record() + parsed = json.loads(format_jsonld_context_record(record)) + assert parsed["_meta"]["evidence_type"] == "EvidenceCertificate" + + def test_meta_contains_namespace(self): + record = _valid_record() + parsed = json.loads(format_jsonld_context_record(record)) + assert "namespace" in parsed["_meta"] + + def test_context_has_vocab(self): + record = _valid_record() + parsed = json.loads(format_jsonld_context_record(record)) + assert "@vocab" in parsed["@context"] + + +class TestAnnotateCertificateWithContext: + def test_returns_dict(self): + record = _valid_record() + cert = {"certificate_id": "CERT-001"} + result = annotate_certificate_with_context(cert, record) + assert isinstance(result, dict) + + def test_adds_context_key(self): + record = _valid_record() + cert = {"certificate_id": "CERT-001"} + result = annotate_certificate_with_context(cert, record) + assert "@context" in result + + def test_adds_type_key(self): + record = _valid_record() + cert = {"certificate_id": "CERT-001"} + result = annotate_certificate_with_context(cert, record) + assert "@type" in result + + def test_type_matches_evidence_type(self): + record = _valid_record("BenchmarkCard") + cert = {} + result = annotate_certificate_with_context(cert, record) + assert result["@type"] == "BenchmarkCard" + + def test_original_fields_preserved(self): + record = _valid_record() + cert = {"certificate_id": "CERT-001", "candidate_id": "TOY-X"} + result = annotate_certificate_with_context(cert, record) + assert result["certificate_id"] == "CERT-001" + assert result["candidate_id"] == "TOY-X" + + def test_does_not_mutate_original(self): + record = _valid_record() + cert = {"certificate_id": "CERT-001"} + original_copy = dict(cert) + annotate_certificate_with_context(cert, record) + assert cert == original_copy