diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ab67a5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/evidence/he-retrieval-qualification-protocol-v1.md text eol=lf diff --git a/build.gradle.kts b/build.gradle.kts index ef65209..cb7e5db 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -136,6 +136,34 @@ tasks.withType().configureEach { isReproducibleFileOrder = true } +tasks.register("brightfieldQualification") { + group = "verification" + description = "Runs synthetic-only cell and IHC pre-qualification checks." + dependsOn(tasks.classes) + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "org.pathlab.forge.evidence.BrightfieldQualificationHarness" + val report = providers.gradleProperty("pathlab.qualification.output") + .orElse(layout.buildDirectory.file("qualification/brightfield-report.json").map { it.asFile.absolutePath }) + args( + "--packs", file("src/main/resources/evidence-packs").absolutePath, + "--output", report.get()) +} + +tasks.register("cellInstanceQualification") { + group = "verification" + description = "Runs the frozen held-out cell-instance qualification evaluator." + dependsOn(tasks.classes) + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "org.pathlab.forge.evidence.CellInstanceQualificationEvaluator" + maxHeapSize = "768m" + val cohort = providers.gradleProperty("pathlab.cell.cohort") + val cohortSha = providers.gradleProperty("pathlab.cell.cohortSha256") + val report = providers.gradleProperty("pathlab.cell.output") + .orElse(layout.buildDirectory.file("qualification/cell-instance-metrics.json") + .map { it.asFile.absolutePath }) + doFirst { setArgs(listOf(cohort.get(), cohortSha.get(), report.get())) } +} + val installVersionedRuntime = tasks.register("installVersionedRuntime") { dependsOn(tasks.installDist) val localAppData = providers.environmentVariable("LOCALAPPDATA") diff --git a/contracts/evidence-mentor/README.md b/contracts/evidence-mentor/README.md new file mode 100644 index 0000000..c1dfc23 --- /dev/null +++ b/contracts/evidence-mentor/README.md @@ -0,0 +1,10 @@ +# Evidence Mentor contracts + +These schemas are the cross-repository truth boundary for the private research MVP. + +- `pathlab.ai-pack/1` describes a locally installed expert pack and its rights/resource gates. +- `pathlab.ai-evidence/1` is the immutable, signed, slide-bound result accepted by Viewer. +- `pathlab.knowledge-pack/1` contains reviewed atomic claims; the tutor may select claim IDs but may not invent factual prose. +- `pathlab.model-qualification-report/1` records checksum-bound synthetic or held-out checks without activating a pack. + +Raw WSI pixels and reusable embeddings are never valid evidence-bundle members. All contracts are research-only and fail closed. diff --git a/contracts/evidence-mentor/pathlab-ai-evidence-v1.schema.json b/contracts/evidence-mentor/pathlab-ai-evidence-v1.schema.json new file mode 100644 index 0000000..bf74b14 --- /dev/null +++ b/contracts/evidence-mentor/pathlab-ai-evidence-v1.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pathlab.local/schemas/pathlab-ai-evidence-v1.schema.json", + "title": "PathLab AI Evidence v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "bundleId", "source", "pack", "status", "researchOnly", "notDiagnostic", "reviewRequired", "coordinates", "evidence", "cellAggregates", "ihcDescriptors", "citations", "qc", "provenance", "manifestSha256", "signature"], + "properties": { + "schema": {"const": "pathlab.ai-evidence/1"}, + "bundleId": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$"}, + "source": {"type": "object", "additionalProperties": false, "required": ["slideSha256", "revision"], "properties": {"slideSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "revision": {"type": "string", "minLength": 1, "maxLength": 160}}}, + "pack": {"type": "object", "additionalProperties": false, "required": ["id", "version", "manifestSha256", "preprocessing", "artifacts", "allowedUse", "validationStatus"], "properties": {"id": {"type": "string", "maxLength": 120}, "version": {"type": "string", "maxLength": 64}, "manifestSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "preprocessing": {"type": "string", "maxLength": 120}, "artifacts": {"type": "array", "maxItems": 16, "items": {"type": "string", "pattern": "^[a-f0-9]{64}$"}}, "allowedUse": {"const": "private-research"}, "validationStatus": {"enum": ["experimental", "qualified"]}}}, + "status": {"enum": ["completed", "partial", "abstained", "unsupported", "failed"]}, + "researchOnly": {"const": true}, + "notDiagnostic": {"const": true}, + "reviewRequired": {"const": true}, + "coordinates": {"type": "object", "additionalProperties": false, "required": ["space", "originX", "originY", "scaleX", "scaleY"], "properties": {"space": {"const": "source-pixel"}, "originX": {"type": "number", "minimum": 0}, "originY": {"type": "number", "minimum": 0}, "scaleX": {"type": "number", "exclusiveMinimum": 0}, "scaleY": {"type": "number", "exclusiveMinimum": 0}}}, + "evidence": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/region"}}, + "cellAggregates": {"type": "array", "maxItems": 1000, "items": {"type": "object", "additionalProperties": false, "required": ["regionId", "algorithm", "count", "densityPerMm2", "meanNucleusAreaPx2"], "properties": {"regionId": {"type": "string", "maxLength": 120}, "algorithm": {"enum": ["hovernet-fast", "od-watershed", "od-connected-components"]}, "count": {"type": "integer", "minimum": 0}, "densityPerMm2": {"type": ["number", "null"], "minimum": 0}, "meanNucleusAreaPx2": {"type": ["number", "null"], "minimum": 0}, "meanNucleusPerimeterPx": {"type": ["number", "null"], "minimum": 0}, "meanNucleusEccentricity": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, "meanNucleusSolidity": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, "uncertainty": {"type": "number", "minimum": 0, "maximum": 1}}}}, + "ihcDescriptors": {"type": "array", "maxItems": 1000, "items": {"type": "object", "additionalProperties": false, "required": ["regionId", "marker", "compartment", "dabAreaFraction", "meanDabOd", "researchEstimate"], "properties": {"regionId": {"type": "string", "maxLength": 120}, "markerId": {"enum": ["generic", "er", "pr", "ki-67", "her2", "pd-l1"]}, "marker": {"enum": ["generic", "er", "pr", "ki-67", "her2", "pd-l1"]}, "markerIdentitySource": {"enum": ["unknown", "faculty-authored", "slide-label", "import-metadata"]}, "analysisMode": {"enum": ["marker-aware", "generic-fallback", "generic-descriptive"]}, "cellMaskSource": {"enum": ["hovernet-fast", "od-watershed", "od-connected-components"]}, "compartmentSource": {"enum": ["none", "faculty-authored", "faculty-approved", "model-suggested"]}, "calibrationStatus": {"enum": ["calibrated", "relative_only", "not_evaluable"]}, "compartment": {"enum": ["nuclear", "membrane", "tumor-immune-region", "generic-region"]}, "dabAreaFraction": {"type": "number", "minimum": 0, "maximum": 1}, "meanDabOd": {"type": "number", "minimum": 0}, "uncertainty": {"type": "number", "minimum": 0, "maximum": 1}, "abstentionReason": {"type": ["string", "null"], "maxLength": 500}, "researchEstimate": {"const": true}}}}, + "citations": {"type": "array", "maxItems": 100, "items": {"type": "object", "additionalProperties": false, "required": ["claimId", "sourceUrl"], "properties": {"claimId": {"type": "string", "maxLength": 160}, "sourceUrl": {"type": "string", "format": "uri", "maxLength": 1000}}}}, + "qc": {"type": "object", "additionalProperties": false, "required": ["focus", "tissueFraction", "uncertainty", "abstentionReasons"], "properties": {"focus": {"type": "number", "minimum": 0, "maximum": 1}, "tissueFraction": {"type": "number", "minimum": 0, "maximum": 1}, "uncertainty": {"type": "number", "minimum": 0, "maximum": 1}, "abstentionReasons": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 500}}, "calibrationStatus": {"enum": ["calibrated", "relative_only", "not_evaluable"]}, "backgroundFraction": {"type": "number", "minimum": 0, "maximum": 1}, "saturationFraction": {"type": "number", "minimum": 0, "maximum": 1}, "stainSeparation": {"type": "number", "minimum": 0, "maximum": 1}, "warnings": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 500}}}}, + "provenance": {"type": "object", "additionalProperties": false, "required": ["createdAt", "codeRevision", "offlineAnalysis"], "properties": {"createdAt": {"type": "string", "format": "date-time"}, "codeRevision": {"type": "string", "maxLength": 160}, "offlineAnalysis": {"const": true}}}, + "manifestSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "signature": {"type": "object", "additionalProperties": false, "required": ["algorithm", "keyId", "publicKeyDer", "value"], "properties": {"algorithm": {"const": "Ed25519"}, "keyId": {"type": "string", "maxLength": 160}, "publicKeyDer": {"type": "string", "minLength": 1}, "value": {"type": "string", "minLength": 1}}} + }, + "$defs": { + "region": {"type": "object", "additionalProperties": false, "required": ["id", "stage", "kind", "x", "y", "width", "height", "score"], "properties": {"id": {"type": "string", "maxLength": 120}, "stage": {"enum": ["coarse", "refined"]}, "kind": {"enum": ["support", "similar", "contrast"]}, "x": {"type": "number", "minimum": 0}, "y": {"type": "number", "minimum": 0}, "width": {"type": "number", "exclusiveMinimum": 0}, "height": {"type": "number", "exclusiveMinimum": 0}, "score": {"type": "number", "minimum": 0, "maximum": 1}, "thumbnail": {"type": "string", "pattern": "^thumbnails/[A-Za-z0-9._-]+\\.(png|jpg|webp)$"}}} + } +} diff --git a/contracts/evidence-mentor/pathlab-ai-evidence-v2.schema.json b/contracts/evidence-mentor/pathlab-ai-evidence-v2.schema.json new file mode 100644 index 0000000..b3ce39f --- /dev/null +++ b/contracts/evidence-mentor/pathlab-ai-evidence-v2.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://pathlab.local/schemas/pathlab-ai-evidence-v2.schema.json", + "title": "PathLab AI Evidence v2", "type": "object", "additionalProperties": false, + "required": ["schema", "bundleId", "source", "pack", "qualificationAttestationSha256", "status", "researchOnly", "notDiagnostic", "reviewRequired", "coordinates", "regions", "cellInstances", "cellAggregates", "ihcDescriptors", "specialStainDescriptors", "cytologyDescriptors", "citations", "qc", "provenance", "manifestSha256", "signature"], + "properties": { + "schema": {"const": "pathlab.ai-evidence/2"}, "bundleId": {"$ref": "#/$defs/id"}, + "source": {"type": "object", "additionalProperties": false, "required": ["slideSha256", "revision", "width", "height"], "properties": {"slideSha256": {"$ref": "#/$defs/sha"}, "revision": {"type": "string", "minLength": 1, "maxLength": 160}, "width": {"type": "integer", "minimum": 1}, "height": {"type": "integer", "minimum": 1}}}, + "pack": {"type": "object", "additionalProperties": false, "required": ["id", "version", "manifestSha256", "capability", "scope", "preprocessing", "artifacts"], "properties": {"id": {"$ref": "#/$defs/id"}, "version": {"type": "string", "maxLength": 64}, "manifestSha256": {"$ref": "#/$defs/sha"}, "capability": {"type": "string", "maxLength": 120}, "scope": {"enum": ["deployment", "research-restricted"]}, "preprocessing": {"type": "string", "maxLength": 160}, "artifacts": {"type": "array", "maxItems": 32, "items": {"$ref": "#/$defs/sha"}}}}, + "qualificationAttestationSha256": {"$ref": "#/$defs/sha"}, "status": {"enum": ["completed", "partial", "abstained", "unsupported", "failed"]}, + "researchOnly": {"const": true}, "notDiagnostic": {"const": true}, "reviewRequired": {"const": true}, + "coordinates": {"type": "object", "additionalProperties": false, "required": ["space", "originX", "originY", "scaleX", "scaleY"], "properties": {"space": {"const": "source-pixel"}, "originX": {"type": "number", "minimum": 0}, "originY": {"type": "number", "minimum": 0}, "scaleX": {"type": "number", "exclusiveMinimum": 0}, "scaleY": {"type": "number", "exclusiveMinimum": 0}}}, + "regions": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/region"}}, + "cellInstances": {"type": "array", "maxItems": 100000, "items": {"type": "object", "additionalProperties": false, "required": ["id", "regionId", "maskEncoding", "mask", "areaPx2", "perimeterPx", "eccentricity", "solidity", "meanIntensity", "uncertainty", "category"], "properties": {"id": {"$ref": "#/$defs/id"}, "regionId": {"$ref": "#/$defs/id"}, "maskEncoding": {"enum": ["polygon", "rle"]}, "mask": {"type": "array", "minItems": 3, "maxItems": 4096, "items": {"type": "number", "minimum": 0}}, "areaPx2": {"type": "number", "minimum": 0}, "perimeterPx": {"type": "number", "minimum": 0}, "eccentricity": {"type": "number", "minimum": 0, "maximum": 1}, "solidity": {"type": "number", "minimum": 0, "maximum": 1}, "meanIntensity": {"type": "number", "minimum": 0, "maximum": 1}, "uncertainty": {"type": "number", "minimum": 0, "maximum": 1}, "category": {"enum": ["unclassified", "model-category-1", "model-category-2", "model-category-3", "model-category-4", "model-category-5"]}}}}, + "cellAggregates": {"type": "array", "maxItems": 1000, "items": {"type": "object", "additionalProperties": false, "required": ["regionId", "algorithm", "count", "densityPerMm2", "distributions", "uncertainty", "qc"], "properties": {"regionId": {"$ref": "#/$defs/id"}, "algorithm": {"enum": ["hovernet-fast", "pathosam", "od-watershed"]}, "count": {"type": "integer", "minimum": 0}, "densityPerMm2": {"type": ["number", "null"], "minimum": 0}, "distributions": {"type": "object"}, "uncertainty": {"type": "number", "minimum": 0, "maximum": 1}, "qc": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 200}}}}}, + "ihcDescriptors": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/stainDescriptor"}}, + "specialStainDescriptors": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/stainDescriptor"}}, + "cytologyDescriptors": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/stainDescriptor"}}, + "citations": {"type": "array", "maxItems": 100, "items": {"type": "object", "additionalProperties": false, "required": ["claimId", "sourceUrl"], "properties": {"claimId": {"$ref": "#/$defs/id"}, "sourceUrl": {"type": "string", "format": "uri", "maxLength": 1000}}}}, + "qc": {"type": "object", "additionalProperties": false, "required": ["focus", "tissueFraction", "uncertainty", "abstentionReasons", "warnings"], "properties": {"focus": {"type": "number", "minimum": 0, "maximum": 1}, "tissueFraction": {"type": "number", "minimum": 0, "maximum": 1}, "uncertainty": {"type": "number", "minimum": 0, "maximum": 1}, "abstentionReasons": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 500}}, "warnings": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 500}}}}, + "provenance": {"type": "object", "additionalProperties": false, "required": ["createdAt", "codeRevision", "offlineAnalysis", "campaignId"], "properties": {"createdAt": {"type": "string", "format": "date-time"}, "codeRevision": {"type": "string", "maxLength": 160}, "offlineAnalysis": {"const": true}, "campaignId": {"type": ["string", "null"], "maxLength": 120}}}, + "manifestSha256": {"$ref": "#/$defs/sha"}, "signature": {"$ref": "#/$defs/signature"} + }, + "$defs": { + "id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,160}$"}, "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "region": {"type": "object", "additionalProperties": false, "required": ["id", "stage", "kind", "reviewStatus", "x", "y", "width", "height", "score"], "properties": {"id": {"$ref": "#/$defs/id"}, "stage": {"enum": ["coarse", "refined"]}, "kind": {"enum": ["support", "similar", "contrast", "analysis", "tumor-compartment", "immune-compartment"]}, "reviewStatus": {"enum": ["unreviewed", "faculty-authored", "faculty-approved"]}, "x": {"type": "number", "minimum": 0}, "y": {"type": "number", "minimum": 0}, "width": {"type": "number", "exclusiveMinimum": 0}, "height": {"type": "number", "exclusiveMinimum": 0}, "score": {"type": "number", "minimum": 0, "maximum": 1}, "thumbnail": {"type": "string", "pattern": "^thumbnails/[A-Za-z0-9._-]+\\.(png|jpg|webp)$"}}}, + "stainDescriptor": {"type": "object", "additionalProperties": false, "required": ["regionId", "stainId", "markerId", "analysisMode", "cellMaskSource", "compartmentSource", "calibrationStatus", "measurements", "qc", "uncertainty", "abstentionReason", "researchEstimate"], "properties": {"regionId": {"$ref": "#/$defs/id"}, "stainId": {"enum": ["ihc_dab", "pas", "pas_d", "trichrome", "gms", "afb", "papanicolaou", "generic_brightfield"]}, "markerId": {"enum": ["generic", "er", "pr", "ki-67", "her2", "pd-l1", "none"]}, "analysisMode": {"enum": ["marker-aware", "generic-fallback", "generic-descriptive", "special-stain-descriptive", "cytology-descriptive"]}, "cellMaskSource": {"enum": ["hovernet-fast", "pathosam", "od-watershed", "none"]}, "compartmentSource": {"enum": ["none", "faculty-authored", "faculty-approved", "model-suggested"]}, "calibrationStatus": {"enum": ["calibrated", "relative_only", "not_evaluable"]}, "measurements": {"type": "object"}, "qc": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 200}}, "uncertainty": {"type": "number", "minimum": 0, "maximum": 1}, "abstentionReason": {"type": ["string", "null"], "maxLength": 500}, "researchEstimate": {"const": true}}}, + "signature": {"type": "object", "additionalProperties": false, "required": ["algorithm", "keyId", "value"], "properties": {"algorithm": {"const": "Ed25519"}, "keyId": {"$ref": "#/$defs/sha"}, "value": {"type": "string", "minLength": 1, "maxLength": 1000}}} + } +} diff --git a/contracts/evidence-mentor/pathlab-ai-pack-v1.schema.json b/contracts/evidence-mentor/pathlab-ai-pack-v1.schema.json new file mode 100644 index 0000000..ddf56a2 --- /dev/null +++ b/contracts/evidence-mentor/pathlab-ai-pack-v1.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pathlab.local/schemas/pathlab-ai-pack-v1.schema.json", + "title": "PathLab AI Pack v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "packId", "version", "capability", "acceptedStains", "preprocessing", "artifacts", "rights", "resourceEnvelope", "validation", "outputSchema"], + "properties": { + "schema": {"const": "pathlab.ai-pack/1"}, + "packId": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,119}$"}, + "version": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}, + "capability": {"enum": ["he-evidence", "cell-morphology", "ihc-descriptive"]}, + "acceptedStains": {"type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"enum": ["he", "ihc_dab"]}}, + "preprocessing": {"type": "object", "additionalProperties": false, "required": ["id", "tilePixels"], "properties": {"id": {"type": "string", "maxLength": 120}, "tilePixels": {"type": "integer", "minimum": 64, "maximum": 2048}}}, + "artifacts": {"type": "array", "maxItems": 16, "items": {"type": "object", "additionalProperties": false, "required": ["name", "sha256", "source"], "properties": {"name": {"type": "string", "maxLength": 160}, "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "source": {"type": "string", "format": "uri", "maxLength": 1000}}}}, + "rights": {"type": "object", "additionalProperties": false, "required": ["license", "allowedUse", "redistributable", "derivativesAllowed", "reviewedAt"], "properties": {"license": {"type": "string", "maxLength": 200}, "allowedUse": {"enum": ["private-research", "benchmark-only"]}, "redistributable": {"type": "boolean"}, "derivativesAllowed": {"type": "boolean"}, "reviewedAt": {"type": "string", "format": "date-time"}}}, + "resourceEnvelope": {"type": "object", "additionalProperties": false, "required": ["maxRamMiB", "maxVramMiB", "maxSeconds", "network"], "properties": {"maxRamMiB": {"type": "integer", "minimum": 64, "maximum": 16384}, "maxVramMiB": {"type": "integer", "minimum": 0, "maximum": 4608}, "maxSeconds": {"type": "integer", "minimum": 1, "maximum": 1200}, "network": {"const": false}}}, + "runtimeCompatibility": {"type": "object", "additionalProperties": false, "required": ["workerProtocol", "executionProvider", "cuda", "gpuArchitecture", "requiresExternalWorker"], "properties": {"workerProtocol": {"type": "string", "maxLength": 80}, "executionProvider": {"enum": ["cpu", "cuda"]}, "cuda": {"type": "string", "maxLength": 40}, "gpuArchitecture": {"type": "string", "maxLength": 40}, "requiresExternalWorker": {"type": "boolean"}}}, + "licenseLedger": {"type": "array", "maxItems": 32, "items": {"type": "object", "additionalProperties": false, "required": ["component", "license", "revision", "permittedUse", "redistributable", "derivativesAllowed"], "properties": {"component": {"type": "string", "maxLength": 160}, "license": {"type": "string", "maxLength": 200}, "revision": {"type": "string", "maxLength": 160}, "permittedUse": {"enum": ["private-research", "benchmark-only"]}, "redistributable": {"type": "boolean"}, "derivativesAllowed": {"type": "boolean"}}}}, + "validation": {"type": "object", "additionalProperties": false, "required": ["status", "modelCard", "heldOutEvaluation"], "properties": {"status": {"enum": ["experimental", "qualified", "not-evaluable", "unsupported", "blocked"]}, "modelCard": {"type": "string", "maxLength": 1000}, "heldOutEvaluation": {"type": "string", "maxLength": 1000}}}, + "outputSchema": {"const": "pathlab.ai-evidence/1"}, + "markers": {"type": "array", "uniqueItems": true, "items": {"enum": ["generic", "er", "pr", "ki-67", "her2", "pd-l1"]}} + } +} diff --git a/contracts/evidence-mentor/pathlab-ai-pack-v2.schema.json b/contracts/evidence-mentor/pathlab-ai-pack-v2.schema.json new file mode 100644 index 0000000..63bc46d --- /dev/null +++ b/contracts/evidence-mentor/pathlab-ai-pack-v2.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pathlab.local/schemas/pathlab-ai-pack-v2.schema.json", + "title": "PathLab AI Pack v2", + "type": "object", "additionalProperties": false, + "required": ["schema", "packId", "version", "capability", "scope", "activationEligible", "acceptedStains", "preprocessing", "artifacts", "runtimeCompatibility", "licenseLedger", "derivativePermissions", "resourceEnvelope", "qualificationPolicy", "outputSchema"], + "properties": { + "schema": {"const": "pathlab.ai-pack/2"}, + "packId": {"$ref": "#/$defs/id"}, "version": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}, + "capability": {"enum": ["he-evidence", "cell-morphology", "ihc-descriptive", "special-stain-descriptive", "cytology-descriptive", "grounded-tutor", "atlas-distillation"]}, + "scope": {"enum": ["deployment", "research-restricted", "local-benchmark"]}, + "activationEligible": {"type": "boolean"}, + "acceptedStains": {"type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"enum": ["he", "ihc_dab", "pas", "pas_d", "trichrome", "gms", "afb", "papanicolaou", "generic_brightfield"]}}, + "preprocessing": {"type": "object", "additionalProperties": false, "required": ["id", "tilePixels"], "properties": {"id": {"type": "string", "maxLength": 120}, "tilePixels": {"type": "integer", "minimum": 64, "maximum": 2048}}}, + "artifacts": {"type": "array", "maxItems": 32, "items": {"type": "object", "additionalProperties": false, "required": ["name", "sha256", "source"], "properties": {"name": {"type": "string", "maxLength": 160}, "sha256": {"$ref": "#/$defs/sha"}, "source": {"type": "string", "maxLength": 1000}}}}, + "runtimeCompatibility": {"type": "object", "additionalProperties": false, "required": ["workerProtocol", "executionProvider", "cuda", "gpuArchitecture", "requiresExternalWorker", "segmentMaxSeconds"], "properties": {"workerProtocol": {"enum": ["pathlab.model-worker-result/1", "pathlab.model-worker-result/2"]}, "executionProvider": {"enum": ["cpu", "cuda", "webgpu", "external"]}, "cuda": {"type": "string", "maxLength": 40}, "gpuArchitecture": {"type": "string", "maxLength": 40}, "requiresExternalWorker": {"type": "boolean"}, "segmentMaxSeconds": {"type": "integer", "minimum": 1, "maximum": 1200}}}, + "licenseLedger": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"type": "object", "additionalProperties": false, "required": ["component", "kind", "license", "revision", "permittedUse", "redistributable", "derivativesAllowed"], "properties": {"component": {"type": "string", "maxLength": 160}, "kind": {"enum": ["code", "weights", "dataset", "runtime"]}, "license": {"type": "string", "maxLength": 240}, "revision": {"type": "string", "maxLength": 160}, "permittedUse": {"enum": ["private-research", "research-restricted", "benchmark-only"]}, "redistributable": {"type": "boolean"}, "derivativesAllowed": {"type": "boolean"}}}}, + "derivativePermissions": {"type": "object", "additionalProperties": false, "required": ["atlasResearch", "atlasClean", "reviewedAt"], "properties": {"atlasResearch": {"type": "boolean"}, "atlasClean": {"type": "boolean"}, "reviewedAt": {"type": "string", "format": "date-time"}}}, + "resourceEnvelope": {"type": "object", "additionalProperties": false, "required": ["lane", "maxRamMiB", "maxVramMiB", "maxSeconds", "network"], "properties": {"lane": {"enum": ["gpu", "cpu_io", "external"]}, "maxRamMiB": {"type": "integer", "minimum": 64, "maximum": 16384}, "maxVramMiB": {"type": "integer", "minimum": 0, "maximum": 4608}, "maxSeconds": {"type": "integer", "minimum": 1, "maximum": 1200}, "network": {"const": false}}}, + "qualificationPolicy": {"type": "object", "additionalProperties": false, "required": ["requiresSignedAttestation", "protocolSha256"], "properties": {"requiresSignedAttestation": {"const": true}, "protocolSha256": {"$ref": "#/$defs/sha"}}}, + "outputSchema": {"enum": ["pathlab.ai-evidence/2", "pathlab.model-qualification-report/2", "pathlab.model-worker-result/2"]}, + "markers": {"type": "array", "uniqueItems": true, "items": {"enum": ["generic", "er", "pr", "ki-67", "her2", "pd-l1"]}} + }, + "$defs": {"id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,119}$"}, "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}} +} diff --git a/contracts/evidence-mentor/pathlab-capability-matrix-v1.schema.json b/contracts/evidence-mentor/pathlab-capability-matrix-v1.schema.json new file mode 100644 index 0000000..374f881 --- /dev/null +++ b/contracts/evidence-mentor/pathlab-capability-matrix-v1.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://pathlab.local/schemas/pathlab-capability-matrix-v1.schema.json", + "title": "PathLab Capability Matrix v1", "type": "object", "additionalProperties": false, + "required": ["schema", "campaignId", "campaignManifestSha256", "campaignCompleted", "campaignTargetMet", "generatedAt", "tracks", "manifestSha256", "signature"], + "properties": { + "schema": {"const": "pathlab.capability-matrix/1"}, "campaignId": {"$ref": "#/$defs/id"}, "campaignManifestSha256": {"$ref": "#/$defs/sha"}, + "campaignCompleted": {"type": "boolean"}, "campaignTargetMet": {"type": "boolean"}, "generatedAt": {"type": "string", "format": "date-time"}, + "tracks": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"type": "object", "additionalProperties": false, "required": ["trackId", "candidateId", "capability", "scope", "status", "activationEligible", "qualificationReportSha256", "failureCode"], "properties": {"trackId": {"$ref": "#/$defs/id"}, "candidateId": {"$ref": "#/$defs/id"}, "capability": {"type": "string", "maxLength": 120}, "scope": {"enum": ["deployment", "research-restricted", "local-benchmark"]}, "status": {"enum": ["qualified", "experimental", "unsupported", "not_evaluable"]}, "activationEligible": {"type": "boolean"}, "qualificationReportSha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, "failureCode": {"type": ["string", "null"], "maxLength": 120}}}}, + "manifestSha256": {"$ref": "#/$defs/sha"}, "signature": {"$ref": "#/$defs/signature"} + }, + "$defs": {"id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$"}, "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "signature": {"type": "object", "additionalProperties": false, "required": ["algorithm", "keyId", "value"], "properties": {"algorithm": {"const": "Ed25519"}, "keyId": {"$ref": "#/$defs/sha"}, "value": {"type": "string", "minLength": 1, "maxLength": 1000}}}} +} diff --git a/contracts/evidence-mentor/pathlab-evidence-set-v1.schema.json b/contracts/evidence-mentor/pathlab-evidence-set-v1.schema.json new file mode 100644 index 0000000..4fccacb --- /dev/null +++ b/contracts/evidence-mentor/pathlab-evidence-set-v1.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://pathlab.local/schemas/pathlab-evidence-set-v1.schema.json", + "title": "PathLab Evidence Set v1", "type": "object", "additionalProperties": false, + "required": ["schema", "setId", "source", "bundles", "reviewRequired", "manifestSha256", "signature"], + "properties": { + "schema": {"const": "pathlab.evidence-set/1"}, "setId": {"$ref": "#/$defs/id"}, + "source": {"type": "object", "additionalProperties": false, "required": ["slideSha256", "revision"], "properties": {"slideSha256": {"$ref": "#/$defs/sha"}, "revision": {"type": "string", "minLength": 1, "maxLength": 160}}}, + "bundles": {"type": "array", "minItems": 1, "maxItems": 32, "items": {"type": "object", "additionalProperties": false, "required": ["capability", "packId", "manifestSha256", "qualificationAttestationSha256"], "properties": {"capability": {"type": "string", "maxLength": 120}, "packId": {"$ref": "#/$defs/id"}, "manifestSha256": {"$ref": "#/$defs/sha"}, "qualificationAttestationSha256": {"$ref": "#/$defs/sha"}}}}, + "reviewRequired": {"const": true}, "manifestSha256": {"$ref": "#/$defs/sha"}, "signature": {"$ref": "#/$defs/signature"} + }, "$defs": {"id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,160}$"}, "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "signature": {"type": "object", "additionalProperties": false, "required": ["algorithm", "keyId", "value"], "properties": {"algorithm": {"const": "Ed25519"}, "keyId": {"$ref": "#/$defs/sha"}, "value": {"type": "string", "minLength": 1, "maxLength": 1000}}}} +} diff --git a/contracts/evidence-mentor/pathlab-knowledge-pack-v1.schema.json b/contracts/evidence-mentor/pathlab-knowledge-pack-v1.schema.json new file mode 100644 index 0000000..f675662 --- /dev/null +++ b/contracts/evidence-mentor/pathlab-knowledge-pack-v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pathlab.local/schemas/pathlab-knowledge-pack-v1.schema.json", + "title": "PathLab Knowledge Pack v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "packId", "version", "language", "claims", "checksum"], + "properties": { + "schema": {"const": "pathlab.knowledge-pack/1"}, + "packId": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,119}$"}, + "version": {"type": "string", "maxLength": 64}, + "language": {"const": "en"}, + "claims": {"type": "array", "minItems": 1, "maxItems": 10000, "items": {"type": "object", "additionalProperties": false, "required": ["id", "text", "retrievalText", "source", "license", "allowedUse", "reviewedAt"], "properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,160}$"}, "text": {"type": "string", "minLength": 1, "maxLength": 1000}, "retrievalText": {"type": "string", "minLength": 1, "maxLength": 4000}, "source": {"type": "object", "additionalProperties": false, "required": ["title", "url", "revision"], "properties": {"title": {"type": "string", "maxLength": 500}, "url": {"type": "string", "format": "uri", "maxLength": 1000}, "revision": {"type": "string", "maxLength": 160}}}, "license": {"type": "string", "maxLength": 200}, "allowedUse": {"const": "private-research-education"}, "reviewedAt": {"type": "string", "format": "date-time"}, "tags": {"type": "array", "maxItems": 32, "uniqueItems": true, "items": {"type": "string", "maxLength": 80}}}}}, + "checksum": {"type": "string", "pattern": "^[a-f0-9]{64}$"} + } +} diff --git a/contracts/evidence-mentor/pathlab-model-qualification-report-v1.schema.json b/contracts/evidence-mentor/pathlab-model-qualification-report-v1.schema.json new file mode 100644 index 0000000..d11bd9d --- /dev/null +++ b/contracts/evidence-mentor/pathlab-model-qualification-report-v1.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pathlab.local/schemas/pathlab-model-qualification-report-v1.schema.json", + "title": "PathLab Model Qualification Report v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "protocolId", "protocolSha256", "generatedAt", "syntheticOnly", "researchOnly", "notDiagnostic", "overallStatus", "tracks"], + "properties": { + "schema": {"const": "pathlab.model-qualification-report/1"}, + "protocolId": {"const": "pathlab-brightfield-synthetic-qualification-v1"}, + "protocolSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "syntheticOnly": {"const": true}, + "researchOnly": {"const": true}, + "notDiagnostic": {"const": true}, + "overallStatus": {"enum": ["qualified", "experimental", "unsupported", "not_evaluable"]}, + "tracks": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["capability", "packId", "packVersion", "packManifestSha256", "status", "checks", "reasons"], + "properties": { + "capability": {"enum": ["cell-morphology", "ihc-descriptive"]}, + "packId": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$"}, + "packVersion": {"type": "string", "maxLength": 64}, + "packManifestSha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "status": {"enum": ["qualified", "experimental", "unsupported", "not_evaluable"]}, + "checks": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "outcome"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,119}$"}, + "outcome": {"enum": ["pass", "fail", "not_evaluable"]}, + "observed": {"type": "number"}, + "required": {"type": "number"}, + "tolerance": {"type": "number", "minimum": 0}, + "detail": {"type": "string", "maxLength": 500} + } + } + }, + "reasons": {"type": "array", "maxItems": 50, "items": {"type": "string", "pattern": "^[A-Z0-9_]{1,120}$"}} + } + } + } + } +} diff --git a/contracts/evidence-mentor/pathlab-model-qualification-report-v2.schema.json b/contracts/evidence-mentor/pathlab-model-qualification-report-v2.schema.json new file mode 100644 index 0000000..acc9aee --- /dev/null +++ b/contracts/evidence-mentor/pathlab-model-qualification-report-v2.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://pathlab.local/schemas/pathlab-model-qualification-report-v2.schema.json", + "title": "PathLab Model Qualification Report v2", "type": "object", "additionalProperties": false, + "required": ["schema", "campaignId", "campaignManifestSha256", "trackId", "candidateId", "capability", "scope", "packManifestSha256", "protocolSha256", "generatedAt", "researchOnly", "notDiagnostic", "status", "checks", "reasons", "manifestSha256", "signature"], + "properties": { + "schema": {"const": "pathlab.model-qualification-report/2"}, "campaignId": {"$ref": "#/$defs/id"}, "campaignManifestSha256": {"$ref": "#/$defs/sha"}, + "trackId": {"$ref": "#/$defs/id"}, "candidateId": {"$ref": "#/$defs/id"}, + "capability": {"enum": ["he-evidence", "cell-morphology", "ihc-descriptive", "special-stain-descriptive", "cytology-descriptive", "grounded-tutor", "atlas-distillation"]}, + "scope": {"enum": ["deployment", "research-restricted", "local-benchmark"]}, "packManifestSha256": {"$ref": "#/$defs/sha"}, "protocolSha256": {"$ref": "#/$defs/sha"}, + "generatedAt": {"type": "string", "format": "date-time"}, "researchOnly": {"const": true}, "notDiagnostic": {"const": true}, + "status": {"enum": ["qualified", "experimental", "unsupported", "not_evaluable"]}, + "checks": {"type": "array", "minItems": 1, "maxItems": 200, "items": {"type": "object", "additionalProperties": false, "required": ["id", "outcome"], "properties": {"id": {"$ref": "#/$defs/id"}, "outcome": {"enum": ["pass", "fail", "not_evaluable"]}, "observed": {"type": ["number", "string", "null"]}, "required": {"type": ["number", "string", "null"]}, "detail": {"type": "string", "maxLength": 1000}}}}, + "reasons": {"type": "array", "maxItems": 100, "items": {"type": "string", "pattern": "^[A-Z0-9_]{1,120}$"}}, + "manifestSha256": {"$ref": "#/$defs/sha"}, "signature": {"$ref": "#/$defs/signature"} + }, + "$defs": { + "id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$"}, "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "signature": {"type": "object", "additionalProperties": false, "required": ["algorithm", "keyId", "value"], "properties": {"algorithm": {"const": "Ed25519"}, "keyId": {"$ref": "#/$defs/sha"}, "value": {"type": "string", "minLength": 1, "maxLength": 1000}}} + } +} diff --git a/contracts/evidence-mentor/pathlab-model-worker-result-v2.schema.json b/contracts/evidence-mentor/pathlab-model-worker-result-v2.schema.json new file mode 100644 index 0000000..2ab42a2 --- /dev/null +++ b/contracts/evidence-mentor/pathlab-model-worker-result-v2.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://pathlab.local/schemas/pathlab-model-worker-result-v2.schema.json", + "title": "PathLab Model Worker Result v2", "type": "object", "additionalProperties": false, + "required": ["schema", "jobId", "capability", "packManifestSha256", "terminalStatus", "artifactKind", "artifactPath", "artifactSha256", "completedUnits", "totalUnits", "offlineAnalysis"], + "properties": { + "schema": {"const": "pathlab.model-worker-result/2"}, "jobId": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$"}, + "capability": {"enum": ["he-evidence", "cell-morphology", "ihc-descriptive", "special-stain-descriptive", "cytology-descriptive", "grounded-tutor", "atlas-distillation"]}, + "packManifestSha256": {"$ref": "#/$defs/sha"}, "terminalStatus": {"enum": ["completed", "abstained", "unsupported", "failed"]}, + "artifactKind": {"enum": ["evidence", "qualification", "checkpoint", "browser-attestation", "training-attestation"]}, + "artifactPath": {"type": "string", "minLength": 1, "maxLength": 1000}, "artifactSha256": {"$ref": "#/$defs/sha"}, + "completedUnits": {"type": "integer", "minimum": 0}, "totalUnits": {"type": "integer", "minimum": 0}, + "offlineAnalysis": {"const": true} + }, "$defs": {"sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}} +} diff --git a/contracts/evidence-mentor/pathlab-qualification-campaign-v1.schema.json b/contracts/evidence-mentor/pathlab-qualification-campaign-v1.schema.json new file mode 100644 index 0000000..c263682 --- /dev/null +++ b/contracts/evidence-mentor/pathlab-qualification-campaign-v1.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pathlab.local/schemas/pathlab-qualification-campaign-v1.schema.json", + "title": "PathLab Qualification Campaign v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "campaignId", "createdAt", "researchOnly", "notDiagnostic", "maxRemediationAttempts", "quota", "tracks"], + "properties": { + "schema": {"const": "pathlab.qualification-campaign/1"}, + "campaignId": {"$ref": "#/$defs/id"}, + "createdAt": {"type": "string", "format": "date-time"}, + "researchOnly": {"const": true}, + "notDiagnostic": {"const": true}, + "maxRemediationAttempts": {"const": 1}, + "quota": { + "type": "object", "additionalProperties": false, + "required": ["sourceBytes", "derivedBytes", "modelBytes", "evidenceBytes", "reserveBytes"], + "properties": { + "sourceBytes": {"const": 48318382080}, "derivedBytes": {"const": 26843545600}, + "modelBytes": {"const": 10737418240}, "evidenceBytes": {"const": 10737418240}, + "reserveBytes": {"const": 10737418240} + } + }, + "tracks": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"$ref": "#/$defs/track"}} + }, + "$defs": { + "id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$"}, + "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "track": { + "type": "object", "additionalProperties": false, + "required": ["id", "candidateId", "capability", "scope", "requestPath", "remediationRequestPath", "expectedAttestationPath", "protocolSha256", "dependsOn", "required"], + "properties": { + "id": {"$ref": "#/$defs/id"}, "candidateId": {"$ref": "#/$defs/id"}, + "capability": {"enum": ["he-evidence", "cell-morphology", "ihc-descriptive", "special-stain-descriptive", "cytology-descriptive", "grounded-tutor", "atlas-distillation"]}, + "scope": {"enum": ["deployment", "research-restricted", "local-benchmark"]}, + "requestPath": {"type": "string", "minLength": 1, "maxLength": 1000}, + "remediationRequestPath": {"type": ["string", "null"], "maxLength": 1000}, + "expectedAttestationPath": {"type": ["string", "null"], "maxLength": 1000}, + "protocolSha256": {"$ref": "#/$defs/sha"}, + "dependsOn": {"type": "array", "maxItems": 16, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}}, + "required": {"type": "boolean"} + } + } + } +} diff --git a/contracts/evidence-mentor/pathlab-study-pack-v3.schema.json b/contracts/evidence-mentor/pathlab-study-pack-v3.schema.json new file mode 100644 index 0000000..5ba7337 --- /dev/null +++ b/contracts/evidence-mentor/pathlab-study-pack-v3.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://pathlab.local/schemas/pathlab-study-pack-v3.schema.json", + "title": "PathLab Study Pack v3", "type": "object", "additionalProperties": false, + "required": ["schema", "packKey", "version", "title", "author", "license", "provenance", "revision", "languages", "knowledgePackChecksum", "slides", "tasks", "checksum", "facultyPreview"], + "properties": { + "schema": {"const": "pathlab.study-pack/3"}, "packKey": {"$ref": "#/$defs/id"}, "version": {"type": "integer", "minimum": 1}, + "title": {"type": "string", "minLength": 1, "maxLength": 240}, "author": {"type": "string", "minLength": 1, "maxLength": 240}, "license": {"type": "string", "minLength": 1, "maxLength": 240}, "provenance": {"type": "string", "minLength": 1, "maxLength": 1000}, "revision": {"type": "string", "minLength": 1, "maxLength": 120}, + "languages": {"const": ["en"]}, "knowledgePackChecksum": {"$ref": "#/$defs/sha"}, + "slides": {"type": "array", "minItems": 1, "maxItems": 50, "items": {"type": "object", "additionalProperties": false, "required": ["viewerSlideId", "sha256", "displayName", "evidenceSetSha256"], "properties": {"viewerSlideId": {"type": "string", "maxLength": 100}, "sha256": {"$ref": "#/$defs/sha"}, "displayName": {"type": "string", "maxLength": 200}, "evidenceSetSha256": {"$ref": "#/$defs/sha"}}}}, + "tasks": {"type": "array", "minItems": 1, "maxItems": 500, "items": {"type": "object"}}, + "checksum": {"$ref": "#/$defs/sha"}, "facultyPreview": {"type": "object"} + }, "$defs": {"id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$"}, "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}} +} diff --git a/docs/evidence-mentor-runner.md b/docs/evidence-mentor-runner.md new file mode 100644 index 0000000..66607c3 --- /dev/null +++ b/docs/evidence-mentor-runner.md @@ -0,0 +1,156 @@ +# Evidence Mentor autonomous service + +`EvidenceMentorRunner` is an offline Windows-service workload. It binds only to +`127.0.0.1` on a Windows-assigned port, atomically publishes +`state/endpoint.json`, and authenticates API clients with the existing 256-bit +token in `state/ipc-token`. The endpoint file never contains the token. + +The SQLite WAL queue migrates v1 records in place. It persists the execution +lane, monotonic work units, verified checkpoint identity, lease heartbeat, +rolling throughput, ETA, retry schedule, stable failure classification, and +request/pack/final-artifact checksums. Exactly one GPU job and one low-priority +CPU/I/O job may execute. Live external workers renew their lease no less often +than every 10 seconds; only transient I/O failures receive 5-second, 30-second, +and 2-minute retries. + +## Dashboard + +Forge or the operator launcher uses the bearer token to request a single-use +60-second code and opens `/dashboard/#`. The dashboard exchanges it once +for an HttpOnly, SameSite=Strict boot-scoped cookie, removes the fragment, and +uses a per-session CSRF token for mutations. It polls with ETags every two +seconds while active and ten seconds while idle. + +The dependency-free page exposes operational metadata only. It can pause or +resume new claims, request checkpoint-safe cancellation, and retry failed or +cancelled work after immutable-request verification. It cannot delete jobs, +change priority, activate packs, approve evidence, or publish results. Dashboard +and telemetry failures never alter processing state. + +## Service installation + +First build a versioned distribution: + +```powershell +.\gradlew.bat installDist +``` + +Then run the administrator-approved installer with a Java 17 runtime: + +```powershell +.\scripts\evidence-mentor-service.ps1 -Action Install -Version '2.1.0' ` + -DistributionPath '.\build\install\pathlab-forge' -JavaHome 'C:\Path\To\jdk-17' +``` + +The installer downloads WinSW 2.12.0 from its official release URL and requires +SHA-256 `05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da`. +It installs `PathLabEvidenceMentor` as delayed-auto-start under +`NT AUTHORITY\LocalService`, stages runtimes side-by-side under +`C:\ProgramData\PathLab\EvidenceMentor\runtime\`, keeps state under +`D:\PathLabData\EvidenceMentor\state`, applies service-SID ACLs and outbound-deny +rules, enables the restricted service identity's per-service SID, and rolls back +the active configuration if health checks fail. + +Use `-Action Upgrade` with a new immutable version. `-Action Uninstall` removes +the service, firewall rules, and Start Menu shortcut while preserving queues, +checkpoints, signing material, evidence, and model packs. + +If an elevated install is interrupted after staging but before activation, rerun +the identical command with `-ReuseStagedRuntime`. The installer hashes every +application and Java runtime file against the requested inputs before reuse. It +refuses missing, extra, modified, or mismatched files and never overwrites the +staged version. + +## Host acceptance and the definition of done + +Installation is not acceptance. Run the installed acceptance harness from an +administrator PowerShell window and retain its immutable JSON and Markdown +reports under `D:\PathLabData\EvidenceMentor\state\acceptance`. + +Start with the non-disruptive inspection: + +```powershell +& 'C:\ProgramData\PathLab\EvidenceMentor\Test-PathLab-Evidence-Service.ps1' ` + -Mode Inspect +``` + +Stage a small, rights-approved GPU-pack request under the protected state root. +Its public acceptance ID must match `acceptance-<8-64 lowercase hex chars>`. +The bundled staging command creates a deterministic synthetic H&E tile cache +and a benchmark-only, not-evaluable DINOv2 Session 0 manifest. It does not +activate or qualify the H&E model: + +```powershell +.\scripts\make-dinov2-runtime-portable.ps1 ` + -BasePython 'C:\Path\To\Python312' +.\scripts\stage-dinov2-session0-acceptance.ps1 +``` + +The one-time portability step copies the Python base needed by the external +pack into protected model storage, removes the user-profile runtime dependency, +and extends the checksum ledger. It refuses to overwrite an existing portable +base. Forge itself still contains no Python, PyTorch, CUDA, or model weights. + +Then prove service restart recovery while that bounded job is active: + +```powershell +& 'C:\ProgramData\PathLab\EvidenceMentor\Test-PathLab-Evidence-Service.ps1' ` + -Mode ServiceRestart ` + -JobRequestPath 'D:\PathLabData\EvidenceMentor\state\acceptance\gpu-session0-v1\request.json' ` + -JobId 'acceptance-0123abcd' ` + -TimeoutMinutes 10 +``` + +Reboot acceptance is deliberately split into two operator-controlled commands. +The harness never initiates a reboot: + +```powershell +& 'C:\ProgramData\PathLab\EvidenceMentor\Test-PathLab-Evidence-Service.ps1' ` + -Mode PrepareReboot ` + -JobRequestPath 'D:\PathLabData\EvidenceMentor\state\acceptance\gpu-session0-v1\request.json' ` + -JobId 'acceptance-89abcdef' + +# Reboot Windows manually while the bounded job is active. Do not log in first. + +& 'C:\ProgramData\PathLab\EvidenceMentor\Test-PathLab-Evidence-Service.ps1' ` + -Mode VerifyReboot ` + -TimeoutMinutes 10 +``` + +Finally aggregate the evidence: + +```powershell +& 'C:\ProgramData\PathLab\EvidenceMentor\Test-PathLab-Evidence-Service.ps1' ` + -Mode Summary +``` + +The autonomous-service phase is done only when the newest `Summary` report has +schema `pathlab.service-acceptance/1`, verdict `PASS`, and no required check is +`FAIL` or `NOT_EVALUABLE`. This includes LocalService identity, delayed startup +and recovery policy, pinned WinSW, ACLs, firewall coverage, dynamic authenticated +IPC, one-time dashboard sessions, P2000 visibility, a completed GPU-lane job, +service-restart recovery, and reboot continuation of an active bounded job. +The reboot continuation check additionally requires the job's terminal update +to occur after the new OS boot time; a job that finished before reboot cannot +satisfy the gate. +Mode-specific `PASS` reports are evidence fragments and are not a completion +claim. `-AllowIncomplete` is intended only for safe diagnostics and cannot turn +an incomplete report into passing evidence. + +## Model boundary + +The deterministic cell/IHC baselines are runnable only with non-identifying +`qualification-` job IDs. Their `experimental` status is not pilot +eligibility. The current cell fallback is connected-component morphometry, not +validated watershed instance segmentation. Current IHC output is generic +within-image H/DAB description; marker-specific requests explicitly fall back, +and PD-L1 refuses compartment claims until reviewed geometry is carried by the +request contract. + +DINOv2-small remains a `not_evaluable` executable candidate until its +cross-tissue held-out cohort is complete. Hibou-B and HoVer-Net remain +`not_evaluable` until exact, rights-approved workers and artifacts pass +qualification. Only `qualified` private-research packs may process ordinary +staff/demo jobs. H&E, cells, IHC, special stains, cytology, and later Atlas +tracks stay independently signed and fail closed; none may emit diagnosis, +clinical categories, TPS/CPS, prognosis, or treatment guidance. diff --git a/docs/evidence/bracs-breast-dinov2-retrieval-result-20260824.md b/docs/evidence/bracs-breast-dinov2-retrieval-result-20260824.md new file mode 100644 index 0000000..c08e1bb --- /dev/null +++ b/docs/evidence/bracs-breast-dinov2-retrieval-result-20260824.md @@ -0,0 +1,48 @@ +# BRACS breast DINOv2 retrieval result — 2026-08-24 + +## Result + +`EXPERIMENTAL`, not qualified and not activated. + +Campaign `dinov2-bracs-breast-retrieval-20260824-v2-engineering-repair` +completed 160/160 GPU work units with zero retry under offline service `2.1.8`. +It evaluated 40 BRACS validation-reference and 40 BRACS test-query ROIs across +breast and benign/reactive groups. The reviewed BRACS split has zero patient +overlap, and every source ROI, derived tile, coordinate, and provenance record +is checksum-bound. + +The first campaign attempt completed inference but failed closed before signing +because its track scope did not match the immutable DINOv2 pack scope. A new +preflight assertion rejects that mismatch before submission. The engineering +repair reused the exact cohort and gates; it was not a label-aware scientific +remediation. + +## Frozen evidence + +- Cohort SHA-256: `c8535d525715e2a9897bf5cceb049333f8045217d2a70fe0b45a0b00718323a3` +- Campaign SHA-256: `6e15eda906df311a896e3827f524d7bfb588e3ad37c0104a7120634118dd7727` +- Signed report manifest: `dbb27032a4e8598d442f02c9208b6a0ed1083b8261a00b37b1b023270fc666e4` +- Complete attestation file SHA-256: `f78fc454ef1e11b83433053b672baa734af4673e8e48aed810cee512eb8e82c5` +- Signed evidence file SHA-256: `44cf468191cdd9c11ac82cb33d44b02f34181a173b50c6c9be68b10dbfa9e7e8` +- Pack SHA-256: `cf28971f3e8aa9c805b83d02d2254b72268603962dd069cca09cda3aaaefac5d` + +## Frozen metrics + +- Macro Recall@5 improvement over color histogram: `-0.153741497` + (required at least `0.05`) +- Macro NDCG@10 improvement: `0.005640305` + (required at least `0.03`) +- Exact ranking repeatability: passed +- Cohort rights and integrity: passed +- Offline bounded execution: passed +- OOD AUROC: `not_evaluable` + +`campaignCompleted=true` and `campaignTargetMet=false`. BRACS is a single +source release, so this cohort does not satisfy the source-held-out gate. GI, +lung, lymph-node, and OOD groups are also absent from this standalone campaign. +The baseline-comparison gates failed, so the result is negative even within its +bounded breast/benign retrieval scope. No threshold is lowered, and no model +activation or tuning against this held-out cohort follows. + +The dataset remains CC BY-NC 4.0 private-research material and is not an +Atlas-Clean supervision source without a separate derivative-rights decision. diff --git a/docs/evidence/brightfield-qualification-protocol-v1.md b/docs/evidence/brightfield-qualification-protocol-v1.md new file mode 100644 index 0000000..0977db1 --- /dev/null +++ b/docs/evidence/brightfield-qualification-protocol-v1.md @@ -0,0 +1,44 @@ +# Brightfield synthetic qualification protocol v1 + +Status: executable pre-qualification harness. + +This bounded protocol checks deterministic behavior using generated images only. +It contains no patient material, clinical truth, WSI pixels, or external model +weights. Passing checks cannot qualify or activate a pack. The report remains +`experimental` until independent licensed, tissue-diverse, held-out fixtures and +all pre-registered performance gates pass. + +## Cell checks + +- Two separated synthetic hematoxylin-like objects must be counted as two. +- Two touching objects must be separated into two instances. +- Exact repeated analysis must be identical. +- Reviewed-region instance-mask output must exist before qualification. + +The current deterministic optical-density, distance-transform watershed passes +the bounded separated-object, touching-object, repeatability, and reviewed-region +RLE mask checks. These are synthetic algorithm checks only. Tissue-diverse, +patient/source-held-out instance accuracy, morphometry bias, and failed-region +gates remain pending, so the pack remains `experimental`. + +## IHC checks + +- A synthetic image containing 20% DAB-like pixels must report an area fraction + of 0.2 within `1e-12`. +- Mixed H/DAB-like input without validated controls must remain `relative_only`. +- Weak stain separation must produce `not_evaluable`. +- Marker-aware nuclear and membrane algorithms are present, but their + qualification result remains `not_evaluable` until independent fixtures exist. +- PD-L1 compartment measurement remains `not_evaluable` until the request carries + reviewed compartment geometry. + +Run from the repository root: + +```powershell +.\gradlew.bat brightfieldQualification +``` + +The default report is written atomically to +`build/qualification/brightfield-report.json`. It uses +`pathlab.model-qualification-report/1` and binds each track to the exact pack +manifest checksum. diff --git a/docs/evidence/candidate-preflight-protocol-v1.md b/docs/evidence/candidate-preflight-protocol-v1.md new file mode 100644 index 0000000..9f69af2 --- /dev/null +++ b/docs/evidence/candidate-preflight-protocol-v1.md @@ -0,0 +1,7 @@ +# Candidate preflight protocol v1 + +This protocol records whether a frozen candidate manifest is executable for a qualification job. It is not a scientific model evaluation and cannot produce a `qualified` result. + +A candidate is `not_evaluable` when its pack manifest is absent, malformed, unapproved for qualification execution, missing a rights-cleared artifact, or blocked by a frozen resource or product constraint. The coordinator records the stable failure code `CANDIDATE_PREFLIGHT_FAILED`, completes the track, and signs the resulting capability matrix with the configured local signer. + +Transient database and filesystem I/O errors are not converted into scientific terminal states. Activation remains off, no learner evidence is emitted, and no diagnostic or clinical claim is permitted. diff --git a/docs/evidence/cell-ihc-source-rights-review-20260823.md b/docs/evidence/cell-ihc-source-rights-review-20260823.md new file mode 100644 index 0000000..1a27c2c --- /dev/null +++ b/docs/evidence/cell-ihc-source-rights-review-20260823.md @@ -0,0 +1,42 @@ +# Cell and IHC source-rights review — 2026-08-23 + +Status: bounded preparation approved; scientific qualification not started. + +## MoNuSAC 2020 cell annotations + +The official challenge data page declares CC BY-NC-SA 4.0 and describes expert +nuclear-boundary annotations across lung, prostate, kidney, and breast. This is +eligible only for the restricted private-research lineage. It is not eligible +for Atlas-Clean, commercial deployment, or any clinical claim. The exact +official Google Drive IDs and response sizes are now pinned for the training +archive (545,564,883 bytes), test archive (202,746,703 bytes), and supplementary +organ metadata (19,590,377 bytes). The organizer does not publish cryptographic +checksums for these files. The acquisition therefore freezes local SHA-256 +values after exact-size transfer and remains `not_evaluable` until archive +metadata is extracted and upstream integrity is independently corroborated; no +mirror or inferred weight license is accepted. + +`acquire-monusac2020.ps1` performs that bounded acquisition in the interactive +user context using 8 MiB range checkpoints and publishes only restricted-use +source-ledger records. It does not submit a model or qualification campaign. + +Source: https://monusac-2020.grand-challenge.org/Data/ + +## TumorQuantAI breast IHC patches + +Zenodo record `21797920`, DOI `10.5281/zenodo.21797920`, declares the dataset CC +BY 4.0. The record contains separate H&E, ER, PR, HER2, and Ki-67 fields from 51 +pseudonymized cases with per-file integrity metadata. It explicitly states that +sections/fields are not registered, provides no pathologist-verified invasive +tumor ROI, laboratory controls, diagnostic truth, or independent clinical +validation, and describes computational measurements as research proxies. + +Therefore this source may support bounded descriptive algorithm/QC fixtures, +but cannot qualify clinical categories, cross-slide cell correspondence, PD-L1, +diagnosis, prognosis, or treatment claims. Dataset, software, and gated model +rights remain separate. `prepare-tumorquantai-ihc-source.ps1` downloads only the +pinned metadata bundle (at most 2 MiB); case archives remain blocked until a +case-disjoint subset and non-clinical reference protocol are frozen within the +source quota. + +Source: https://zenodo.org/records/21797920 diff --git a/docs/evidence/cell-instance-qualification-protocol-v1.md b/docs/evidence/cell-instance-qualification-protocol-v1.md new file mode 100644 index 0000000..3057797 --- /dev/null +++ b/docs/evidence/cell-instance-qualification-protocol-v1.md @@ -0,0 +1,33 @@ +# Cell-instance qualification protocol v1 + +Status: frozen restricted-research evaluation protocol. + +This protocol evaluates the deterministic optical-density watershed fallback on +one annotation-bearing field from each of 23 MoNuSAC test patients across lung, +kidney, breast, and prostate. The two patients appearing in both the published +training and testing archives are excluded before field selection. Selection is +lexicographically deterministic and does not use annotations or model output. + +The immutable gates are: + +- macro panoptic quality at least `0.45`; +- instance Dice at least `0.70`; +- mean absolute count error at most `0.15`; +- median matched-instance area/perimeter bias at most `0.10`; +- failed-region rate at most `0.05`; +- exact deterministic repetition; +- evaluable samples from all four declared tissues; and +- execution within the pack's 120-second and 1,024 MiB RAM envelope. + +Instances are greedily matched by descending intersection-over-union at a +threshold of `0.50`. Panoptic quality uses the standard matched-IoU numerator +and `TP + 0.5 FP + 0.5 FN` denominator. Instance Dice averages matched Dice +while unmatched instances contribute zero. Count error is absolute count error +divided by the reference count. Morphometry bias is the median of the mean +absolute relative area and perimeter errors for matched instances. + +The source lineage is CC BY-NC-SA 4.0 and permits only the restricted private +research track. It is ineligible for Atlas-Clean, commercial use, clinical +claims, diagnosis, or treatment guidance. Locally frozen SHA-256 values bind +the exact official archives; the absence of publisher-supplied cryptographic +checksums must remain visible in the report. diff --git a/docs/evidence/he-retrieval-qualification-protocol-v1.md b/docs/evidence/he-retrieval-qualification-protocol-v1.md new file mode 100644 index 0000000..4cfcb5c --- /dev/null +++ b/docs/evidence/he-retrieval-qualification-protocol-v1.md @@ -0,0 +1,28 @@ +# H&E retrieval qualification protocol v1 + +Status: pre-registered before cross-tissue evaluation. + +This is an internal private-research model qualification protocol. It is not a clinical analytical +procedure, diagnostic validation, ICH Q2(R2) validation, CLSI study, or medical-device performance +claim. Public annotations are research references rather than clinical truth. + +## Frozen design + +- Evaluation groups: breast, GI, lung, lymph node, and benign/reactive. +- Splits: immutable reference, query, and OOD manifests. +- Patient, slide, and declared source-group overlap across splits: zero. +- Minimum reference samples per group: 20. +- Minimum query samples per group: 20. +- Minimum OOD samples: 20. +- Candidate: checksum-pinned DINOv2-small pack. +- Deterministic comparison: `color-histogram-v1` on exactly the same cached 512-pixel RGB tiles. +- Macro recall@5 improvement over the baseline: at least 0.05 absolute. +- Macro nDCG@10 improvement over the baseline: at least 0.03 absolute. +- OOD AUROC: at least 0.80. +- Exact repeated-run ranking agreement is required. +- Any missing rights, checksum, source grouping, group coverage, or split integrity yields + `NOT_EVALUABLE`; it cannot be repaired by lowering criteria after results are observed. + +These thresholds govern only whether DINOv2-small may be called an experimental executable H&E +retrieval baseline. They do not qualify Hibou-B, any diagnostic task, or activation for staff/demo +accounts. Activation continues to require a separately signed `qualified` pack. diff --git a/docs/evidence/hovernet-candidate-acquisition-result-20260824.md b/docs/evidence/hovernet-candidate-acquisition-result-20260824.md new file mode 100644 index 0000000..496dea4 --- /dev/null +++ b/docs/evidence/hovernet-candidate-acquisition-result-20260824.md @@ -0,0 +1,47 @@ +# HoVer-Net candidate acquisition result — 2026-08-24 + +## Result + +The official HoVer-Net code and the official fast-mode MoNuSAC checkpoint are +now acquired and locally checksum-frozen as the restricted research candidate +`hovernet-fast-monusac-v1/1`. + +- Upstream repository: `https://github.com/vqdang/hover_net.git` +- Exact code revision: `67e2ce5e3f1a64a2ece77ad1c24233653a9e0901` +- Code archive SHA-256: `a14857014569fc169371f04832fe96e605f755d35151f59901e1ee7176a05914` +- Weight file: `hovernet_fast_monusac_type_tf2pytorch.tar` +- Weight bytes: `150995854` +- Weight SHA-256: `5b1c642d9884e20c8fa0b80a6cfef793f483d47eaa5df6183baddc3f57e88a35` +- Shared runtime manifest SHA-256: `b52c4d80f914c7e6e56d05e594d9478333d763861b5133e3c82af2a6a17fd942` + +The upstream publisher does not provide a cryptographic checksum for this +Google Drive weight. The SHA-256 above is therefore a local first-acquisition +freeze, not independent publisher corroboration. + +## Rights boundary + +HoVer-Net code is MIT licensed. The checkpoint is tracked separately under the +MoNuSAC-derived `CC-BY-NC-SA-4.0` restricted research lineage. It is eligible +only for the research lineage, is not Atlas-Clean eligible, is not +redistributable by this project, and cannot activate itself. + +## Runtime audit + +The acquired checkpoint loads successfully with `torch.load(..., +weights_only=True)` in the existing checksum-pinned Python 3.12 / PyTorch +2.7.1+cu126 runtime. CUDA 12.6 is available on the P2000 and the checkpoint +contains 668 parameter entries. + +The original inference and post-processing stack is not yet executable in that +runtime. `opencv`, `scipy`, `scikit-image`, `docopt`, and `torchvision` are not +installed. A separate checksum-pinned compatibility overlay or a reviewed +minimal post-processing adapter must be built and tested without mutating the +already-qualified DINOv2 runtime. + +## Qualification status + +`not_evaluable`: acquisition and safe checkpoint loading are complete, but +offline tiled inference, instance post-processing, progress/checkpoint support, +resource compliance, and the frozen 23-patient held-out evaluation have not run. +No learner evidence, activation, clinical cell identity, or Atlas teacher claim +is permitted from this acquisition result. diff --git a/docs/evidence/hovernet-cohort-worker-status-20260824.md b/docs/evidence/hovernet-cohort-worker-status-20260824.md new file mode 100644 index 0000000..229b954 --- /dev/null +++ b/docs/evidence/hovernet-cohort-worker-status-20260824.md @@ -0,0 +1,85 @@ +# HoVer-Net held-out worker integration — 2026-08-24 + +## Result + +`EXPERIMENTAL`, not scientifically qualified and not activated. + +Forge now routes checksum-pinned external cell candidates through the durable +GPU worker path. The HoVer-Net adapter supports the frozen 23-patient MoNuSAC +cohort, immutable per-sample checkpoints, exact resume identity validation, +renewable progress sidecars, two-pass deterministic comparison, and bounded +cell-instance qualification metrics. Qualification jobs remain local benchmark +work and cannot publish learner evidence. + +## First held-out campaign + +Campaign `monusac-hovernet-fast-heldout-20260824-v1` completed autonomously +under installed service `2.1.7`. Its signed report has manifest SHA-256 +`71e3de177f69ed0ede38eb3d38496260798fac110f91513d12c87be4ac57c870`. +The complete attestation file has SHA-256 +`d3fcd6b58558d5838ac8d5a384573b40427e6a19e376ee4a6ccc03f9b831be00`. + +- Macro PQ: `0.3631101775` (required at least `0.45`) +- Instance Dice: `0.3534527081` (required at least `0.70`) +- Count error: `0.6979456676` (required at most `0.15`) +- Median morphometry bias: `0.1407778566` (required at most `0.10`) +- Failed-region rate: `0.1304347826` (required at most `0.05`) +- Deterministic repeat, four-tissue coverage, and resource compliance: passed + +`campaignCompleted=true` and `campaignTargetMet=false`. The report also marked +cohort integrity as failed because the first worker collapsed three sample +failures into a count and could not distinguish inference failures from rights +or checksum failures. This is an attribution defect, not evidence that the +rights gate actually failed. + +The runner recorded only 22/23 terminal work units because it could miss a +final progress sidecar written immediately before child-process exit. The +scientific result and signed report completed, but the operational display was +misleading. + +## Bounded remediation repair + +Runner source `2.1.8` performs one final validated sidecar read after successful +worker exit. Pack `cell-hovernet-fast-monusac-v1/7` preserves bounded per-sample +failure records containing only sample ID, organ, and a stable failure code. It +also keeps inference failures separate from rights, checksum, geometry, and +annotation failures. No gate, sample, weight, or preprocessing rule changed. + +## Actual-host repair evidence + +- Adapter pack: `cell-hovernet-fast-monusac-v1/7` +- Pack SHA-256: `49db0a966d9500e61f109f3196c557f233d1ea58136d38835db315b32c910981` +- Worker SHA-256: `89b1af352df9ac4b573fb9f0dc949db7af3f56f7a3d8c4b5212e744db70d1dd0` +- Runtime-reference SHA-256: `4b9db925098fbd986abeadd4f0692bfcf9036f75edbc5f56748d02bb83934769` +- Probe result SHA-256: `02857aaeab16dc289d7bc1e0397a640d06ed0e989f344fa7e0dcdc7517f08bf3` +- Device/runtime: Quadro P2000, CUDA 12.6, `sm_61` +- Probe output: 72 instances, 532 MiB peak VRAM, 1056.809 MiB peak RAM, + 1.099373 seconds +- Full Gradle tests: passed +- Repository policy verification: passed + +The probe is synthetic runtime conformance only. It is not held-out accuracy. + +## Remaining gate + +Service `2.1.8` was installed after explicit administrator approval. Campaign +`monusac-hovernet-fast-heldout-20260824-v2-remediation` then completed its one +bounded remediation attempt with zero retry and a correct terminal dashboard +record of 23/23 units. Its campaign manifest is SHA-256 +`34c7b82ad6e0f5f4506bc2803e6871383e785b4806165693982189899b85c71a`; +the complete attestation file is SHA-256 +`ad97838a6f7d0f6f0545d8465a73aafbd22bfe0ec516159ce58f368183afb437`; +and its signed manifest is +`ebfbae5c58c9d626e6eb0fe153e38776af995b78029a73551bb71e1232563a30`. + +The verdict remains `experimental`, `campaignCompleted=true`, and +`campaignTargetMet=false`. Metrics reproduced the first campaign exactly, so +all accuracy, count, morphometry, and failed-region gates still fail. The model +must not be activated or tuned against this held-out cohort. The worker now +retains three bounded sample failure records, but qualification-report v2 +includes only their aggregate integrity outcome. Surfacing their stable codes +in a future signed operator report is a remaining observability improvement; it +does not justify another remediation attempt or change this terminal verdict. + +The weight lineage remains CC BY-NC-SA 4.0 restricted private research. It is +not Atlas-Clean eligible and cannot enter OCI or activation through this work. diff --git a/docs/evidence/hovernet-runner-integration-status-20260824.md b/docs/evidence/hovernet-runner-integration-status-20260824.md new file mode 100644 index 0000000..56a7f1f --- /dev/null +++ b/docs/evidence/hovernet-runner-integration-status-20260824.md @@ -0,0 +1,34 @@ +# HoVer-Net runner integration status — 2026-08-24 + +## Implemented + +The Java external-worker boundary now supports +`pathlab.model-runtime-reference/1` packs without copying or mutating the +existing CUDA runtime. Before process launch it: + +- requires the exact shared pack/version path under the configured model root; +- rejects symlink or canonical-path escapes; +- verifies the shared runtime-manifest SHA-256 and every runtime file ledger + entry; +- verifies the exact candidate path, candidate-ledger SHA-256, and weight + SHA-256; +- launches the referenced Python executable with the checksum-verified worker; +- forces offline flags, RAM/VRAM limits, and deterministic CuBLAS workspace + configuration; and +- keeps the existing self-contained DINOv2 executable path backward compatible. + +`pathlab.cell-instance-metrics/1` results now receive bounded schema, finite +metric, cohort-hash, boolean-gate, and reason-array validation before they can +reach qualification reporting. Generic HoVer-Net worker failure output is +filtered through the same allowlisted diagnostic boundary as DINOv2. + +## Remaining boundary + +This source change is not installed in the Windows service and does not yet +cause cell jobs to invoke the external worker. The HoVer-Net worker still +supports only the synthetic runtime-probe request. Cohort loading, atomic +progress/checkpoints, restart resume, exact held-out metrics, and campaign +wiring remain required before a 2.1.7 package can be installed or the +23-patient MoNuSAC campaign can start. + +The model remains inactive and `not_evaluable`. diff --git a/docs/evidence/hovernet-runtime-probe-result-20260824.md b/docs/evidence/hovernet-runtime-probe-result-20260824.md new file mode 100644 index 0000000..5aad0cc --- /dev/null +++ b/docs/evidence/hovernet-runtime-probe-result-20260824.md @@ -0,0 +1,41 @@ +# HoVer-Net P2000 runtime probe — 2026-08-24 + +## Result + +The checksum-pinned HoVer-Net fast MoNuSAC candidate executed twice on the +actual NVIDIA Quadro P2000 through the shared PyTorch 2.7.1+cu126 runtime. The +analysis process was network-disabled and used a separately pinned SciPy 1.18.1 +post-processing overlay. + +- Executable pack: `cell-hovernet-fast-monusac-v1/4` +- Pack manifest SHA-256: `aea386cd50251b7c08e4fe92a48b5be48ef0b75d0e2391f2834cead8f7951987` +- Worker SHA-256: `59449aaf04d3d761c5a8fb09c5a88a7ded5bd8adca94aace1a64a614329e4510` +- Runtime-reference SHA-256: `523b5c26abd941d414204df97382a349296a2521f7ec386883ff66597f610953` +- SciPy overlay-manifest SHA-256: `f09197a4218d66adcbcbd080324fbaccaff96e2e3b724ed68b0516d1931f340d` +- Shared runtime copied: no +- Micro-batch: 1 +- CUDA / architecture: 12.6 / `sm_61` +- Peak VRAM: 532 MiB in both runs +- Peak RAM: 1050.527 MiB and 1050.402 MiB +- Inference/post-processing time: 1.115667 s and 1.252335 s +- Deterministic instance count: 72 in both runs +- Deterministic research-category counts: `1=0, 2=44, 3=0, 4=20` +- Result SHA-256 values: `bd61d244dc8c54f25a7d7b4c335ff18031fafe8a4345bff5e01a896b89e86365` + and `82c1714a6b73cfca395115909cf947b4f244b8235e7e2861d038b73b711203e9` + +Model storage after the build is 6,271,635,279 bytes of the 10 GiB model quota, +with zero outstanding reservation bytes. Versions 1-3 are preserved as local, +inactive failed build/probe records; version 4 is also inactive. + +## Scientific boundary + +This used a deterministic synthetic H&E-like image. It proves checkpoint +loading, CUDA execution, tiling, HoVer-Net output decoding, SciPy instance +post-processing, resource measurement, and deterministic descriptive output on +this host. It does not measure instance Dice, PQ, count error, morphometry bias, +cross-tissue behavior, OOD behavior, clinical cell identity, or diagnostic +performance. + +The candidate remains `not_evaluable` and cannot activate, publish learner +evidence, or enter an Atlas teacher registry. The next gate is service-integrated +execution against the unchanged 23-patient MoNuSAC held-out protocol. diff --git a/docs/evidence/lymph-node-source-rights-review-20260823.md b/docs/evidence/lymph-node-source-rights-review-20260823.md new file mode 100644 index 0000000..a4455ed --- /dev/null +++ b/docs/evidence/lymph-node-source-rights-review-20260823.md @@ -0,0 +1,25 @@ +# Lymph-node source rights review — 2026-08-23 + +Status: `NOT_EVALUABLE` + +No CAMELYON acquisition is authorized by this review. Two official CAMELYON17 +pages present incompatible reuse statements: the Data page describes the data +as open access and the challenge work as CC0, while the Download page states +CC BY-NC-ND 4.0. The exact data-object license, derivative permission, and +patient-level split contract therefore remain unresolved. + +- Data page: https://camelyon17.grand-challenge.org/Data/ +- Download page: https://camelyon17.grand-challenge.org/Download/ + +Required remediation before acquisition: + +1. Obtain one authoritative, artifact-specific license statement from the data + controller. +2. Record whether model evaluation outputs and distilled supervision are + permitted derivatives. +3. Freeze patient, slide, center/source, checksum, and split metadata. +4. Keep restricted research lineage separate from Atlas-Clean. + +Until all four items pass, lymph-node data, results, and supervision must not +enter a qualification campaign, evidence bundle, Study Pack, OCI deployment, +or Atlas-Clean lineage. diff --git a/docs/evidence/monusac-cell-qualification-result-20260824.md b/docs/evidence/monusac-cell-qualification-result-20260824.md new file mode 100644 index 0000000..443a3f6 --- /dev/null +++ b/docs/evidence/monusac-cell-qualification-result-20260824.md @@ -0,0 +1,44 @@ +# MoNuSAC cell-instance qualification result — 2026-08-24 + +Status: `experimental`; frozen target not met. + +The official restricted MoNuSAC acquisition completed all 767,901,963 bytes. +The held-out cohort contains one annotation-bearing field from each of 23 test +patients across lung, kidney, breast, and prostate. `TCGA-MP-A4T7` and +`TCGA-A2-A0ES` were excluded because they also occur in the training archive. +The immutable cohort manifest SHA-256 is +`63885be5c1e271669acf9debaba2419b0b8cddeb6a7e5bf102d751e55a9727c8`. + +Campaign `monusac-od-watershed-heldout-20260824-v1` completed with +`campaignCompleted=true` and `campaignTargetMet=false`. Its manifest SHA-256 is +`cd04a5866a55614d46cac280657e753c46b218ad9829cd02139eaeaf9e6dbc0c` and +signed attestation SHA-256 is +`deced88301b084c055bf44676d1f51cc64f922efb209e6adf363a07d184dc724`. + +The unchanged held-out results were: + +- macro PQ `0.0010429883` versus minimum `0.45`; +- instance Dice `0.0008482910` versus minimum `0.70`; +- mean count error `4.9451488886` versus maximum `0.15`; +- morphometry bias `1.0` versus maximum `0.10`; +- failed-region rate `0.0` versus maximum `0.05`; +- deterministic repeat and four-tissue coverage passed. + +The first evaluator also exceeded the pack's resource envelope, reached about +5.1 GB process working set, took more than 120 seconds, and temporarily blocked +dashboard status responses. Its signed report correctly records resource +failure, so it cannot qualify or activate the pack. + +The bounded-memory correction replaces per-instance full-frame bitsets and +pairwise bitset cloning with compact sorted pixel arrays, bounding-box pruning, +and allocation-free two-pointer intersection. A local run against the exact +same cohort and gates completed in `7.320365099` seconds with `395.943359375` +MiB peak heap and reproduced every scientific metric exactly. Runner `2.1.6` +passed the complete build, but its administrator installation was declined at +the UAC prompt. Therefore no signed remediation campaign has run yet and the +installed service remains `2.1.5`. + +This is a useful negative baseline: deterministic OD watershed is not adequate +as the primary all-rounder cell detector on this cohort. It remains an +automatic fail-closed fallback. The next model candidate is HoVer-Net fast mode, +subject to separate code/weight rights and resource qualification. diff --git a/docs/evidence/tcga-lung-tile-remediation-protocol-v1.md b/docs/evidence/tcga-lung-tile-remediation-protocol-v1.md new file mode 100644 index 0000000..c6d4a99 --- /dev/null +++ b/docs/evidence/tcga-lung-tile-remediation-protocol-v1.md @@ -0,0 +1,23 @@ +# TCGA lung tile-selection remediation protocol v1 + +This is the one allowed remediation after the frozen v1 lung retrieval result. +It is label-blind: project, split, phenotype, retrieval score, and model output +must not participate in tile selection. + +For each unchanged checksum-bound WSI: + +1. Produce a deterministic 2048-pixel overview. +2. Score overview grid points using tissue color and luminance only. +3. Retain at most nine spatially separated candidates. +4. Extract a 512 x 512 level-zero tile at every candidate coordinate. +5. Score each tile using tissue fraction, color separation, luminance range, + and adjacent-pixel gradient energy. +6. Select the highest QC score, breaking ties by `y` and then `x`. + +The patient, source, reference/query, license, model, normalization, and +retrieval protocols remain unchanged. Recall@5 improvement must remain at least +0.05 and NDCG@10 improvement must remain at least 0.03. Exact ranking +repeatability remains mandatory. OOD and full cross-tissue gates remain +independent and cannot be satisfied by this remediation. + +No further lung tile-selection remediation is authorized after this frozen run. diff --git a/docs/model-cards/README.md b/docs/model-cards/README.md new file mode 100644 index 0000000..92637eb --- /dev/null +++ b/docs/model-cards/README.md @@ -0,0 +1,18 @@ +# Evidence Mentor model qualification + +All model packs are private research/education features and default off. A manifest is not proof +that its artifact is installed, licensed, qualified, or publishable. Exact artifact SHA-256 values, +source revisions, rights review, held-out evaluation, deterministic self-test, and P2000 acceptance +must be recorded before a status can move from `not-evaluable`. + +| Track | Candidate | Current status | Activation rule | +|---|---|---|---| +| H&E | DINOv2-small | `not-evaluable` | Experimental only after exact artifact and held-out retrieval run | +| H&E | Hibou-B | `not-evaluable` | User accepts gated terms; then rights, reproducibility, quality and latency gates | +| Cells | HoVer-Net fast | `not-evaluable` | Exact private weights plus PQ/count/morphometry/resource gates | +| Cells | OD fallback | `experimental` | Qualification IDs only; touching-nucleus watershed split and instance-mask gates remain | +| IHC | H/DAB descriptors | `experimental` | Qualification IDs only; generic within-image descriptors until marker-specific fixtures pass | +| Tutor | Qwen3-0.6B INT4 | `not-evaluable` | Complete browser asset at most 500 MiB and claim-ID-only tests | +| Distillation | Atlas-H&E ~72M | `not-evaluable` | Baselines frozen, derivative rights pass, bounded feasibility run succeeds | + +GigaPath remains benchmark-only and cannot contribute evidence to the OCI pilot. diff --git a/docs/model-cards/atlas-he-72m-v1.md b/docs/model-cards/atlas-he-72m-v1.md new file mode 100644 index 0000000..f46d556 --- /dev/null +++ b/docs/model-cards/atlas-he-72m-v1.md @@ -0,0 +1,7 @@ +# Atlas-H&E 72M distillation candidate + +Status: `not-evaluable`. Training has not started because independent baseline packs and lawful +teacher derivative rights are not frozen. This manifest records the gate; it is not a trained +model. A future local probe must remain resumable, stay within the P2000 envelope, improve held-out +validation, and satisfy the frozen retrieval, dense-cell, brightfield, OOD, size, and latency gates. + diff --git a/docs/model-cards/cell-hovernet-fast-monusac-v1.md b/docs/model-cards/cell-hovernet-fast-monusac-v1.md new file mode 100644 index 0000000..7b07376 --- /dev/null +++ b/docs/model-cards/cell-hovernet-fast-monusac-v1.md @@ -0,0 +1,33 @@ +# HoVer-Net fast MoNuSAC — research candidate + +## Intended scope + +Offline, local, descriptive nucleus-instance detection for private pathology +research and engineering evaluation. Predicted type indices are retained only +as research model categories and are not definitive tumor, lymphocyte, stromal, +or diagnostic identities. + +## Lineage + +- Code: official HoVer-Net revision + `67e2ce5e3f1a64a2ece77ad1c24233653a9e0901`, MIT. +- Weights: official fast MoNuSAC checkpoint, locally frozen SHA-256 + `5b1c642d9884e20c8fa0b80a6cfef793f483d47eaa5df6183baddc3f57e88a35`, + tracked as `CC-BY-NC-SA-4.0` restricted research lineage. +- Post-processing overlay: SciPy 1.18.1 wheel SHA-256 + `5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2`, + BSD-3-Clause. +- Runtime: shared checksum-pinned PyTorch 2.7.1+cu126 runtime with `sm_61`. + +## Current evidence + +Two synthetic runtime probes on the Quadro P2000 produced identical instance +and research-category counts within 532 MiB VRAM and approximately 1.1 GiB RAM. +This is runtime conformance only. + +## Status and limitations + +`not_evaluable`, inactive, research-restricted, and not Atlas-Clean eligible. +No held-out MoNuSAC accuracy metrics have run through this worker. No diagnosis, +clinical cell identity, prognosis, treatment guidance, or clinical scoring is +supported. diff --git a/docs/model-cards/cell-hovernet-fast-v1.md b/docs/model-cards/cell-hovernet-fast-v1.md new file mode 100644 index 0000000..69f0567 --- /dev/null +++ b/docs/model-cards/cell-hovernet-fast-v1.md @@ -0,0 +1,7 @@ +# HoVer-Net fast cell pack + +Status: `not-evaluable`. No weights are bundled or installed by Forge. The code and weights have +separate license entries; PanNuke-derived weights are private, non-redistributed research inputs. +Qualification requires patient/source-held-out instance PQ, count error, morphometry bias, +cross-tissue failure rate, deterministic repeats, and the declared RAM/VRAM envelope. Predicted +types remain research categories and are not clinical cell identities. diff --git a/docs/model-cards/cell-od-watershed-v1.md b/docs/model-cards/cell-od-watershed-v1.md new file mode 100644 index 0000000..5ba5c35 --- /dev/null +++ b/docs/model-cards/cell-od-watershed-v1.md @@ -0,0 +1,17 @@ +# Optical-density cell fallback + +Status: `experimental`. This deterministic CPU fallback applies bounded optical- +density thresholding, distance-transform markers, watershed instance separation, +and reviewed-region RLE masks. It passes the executable synthetic touching-nuclei, +repeatability, and mask checks. It must nevertheless run only as a +`qualification-` job and must not be activated for staff/demo evidence until +tissue-diverse instance accuracy, morphometry distributions, and failure-rate +gates pass on rights-cleared held-out fixtures. + +Reported counts, area, perimeter, eccentricity, and solidity are descriptive +pixel measurements. They are not clinical cell identities, tumor labels, or a +diagnostic result. No learned cell-type prediction is performed. + +The executable synthetic protocol is documented in +`docs/evidence/brightfield-qualification-protocol-v1.md`. Its current terminal +status remains `experimental`; synthetic checks are not qualification evidence. diff --git a/docs/model-cards/cell-pathosam-benchmark-v1.md b/docs/model-cards/cell-pathosam-benchmark-v1.md new file mode 100644 index 0000000..cea90e7 --- /dev/null +++ b/docs/model-cards/cell-pathosam-benchmark-v1.md @@ -0,0 +1,6 @@ +# PathoSAM cell benchmark candidate + +Status: `not-evaluable`. The repository code is MIT-licensed, but the exact checkpoint revision, +weight lineage, derivative permissions, and P2000 resource behavior have not passed review. No +weights are bundled. This candidate is local benchmark-only and cannot be activated or published. + diff --git a/docs/model-cards/he-dinov2-small-session0-acceptance-v1.md b/docs/model-cards/he-dinov2-small-session0-acceptance-v1.md new file mode 100644 index 0000000..0d33697 --- /dev/null +++ b/docs/model-cards/he-dinov2-small-session0-acceptance-v1.md @@ -0,0 +1,16 @@ +# DINOv2-small Session 0 acceptance pack + +This manifest exists only to prove that the checksum-pinned DINOv2-small worker +can execute offline on the Windows `LocalService` P2000 host. It accepts only +non-identifying `acceptance-` jobs and remains `benchmark-only` and +`not-evaluable`. + +Passing this pack proves runtime compatibility, CUDA 12.6 `sm_61` execution, +resource-envelope telemetry, durable GPU-lane processing, and signed evidence +packaging. It does not qualify H&E retrieval, activate a pilot model, validate +pathology quality, or support diagnostic or clinical claims. + +The installed Python 3.12 base runtime is copied into the protected external +pack directory by `scripts/make-dinov2-runtime-portable.ps1`. The runtime +manifest records the portable base and `pyvenv.cfg` files so `LocalService` +does not depend on or receive access to a Windows user's Python installation. diff --git a/docs/model-cards/he-dinov2-small-v1.md b/docs/model-cards/he-dinov2-small-v1.md new file mode 100644 index 0000000..85788c6 --- /dev/null +++ b/docs/model-cards/he-dinov2-small-v1.md @@ -0,0 +1,122 @@ +# DINOv2-small H&E baseline + +Status: `experimental-executable-baseline`; full qualification remains `not_evaluable`. + +The public `facebook/dinov2-small` repository is pinned at +`ed25f3a31f01632728cabb09d1542f84ab7b0056`. Acquisition accepts only the +88,249,960-byte `model.safetensors` artifact with SHA-256 +`ae1e99fcefd534ed978cdeb8326f08030c96e28b7a81ffcbc98a857c84d14be1`; the +pickle artifact is excluded. Configuration and preprocessing artifacts are +also checksum-pinned in the pack manifest. + +The external feature-pack runtime uses Python 3.12, PyTorch 2.7.1+cu126, and a +compiled Windows launcher. Forge still embeds none of Python, PyTorch, CUDA, or +the weights. A runtime manifest pins the interpreter and critical CUDA/model +files, and the worker rechecks that ledger before loading the model. Network +library entry points and Hugging Face/Transformers online behavior are disabled +during analysis. + +On the local Quadro P2000 qualification host (compute capability 6.1, 5,120 MiB +VRAM), the CUDA build exposed `sm_61` and completed a real device operation. A +three-run synthetic protocol smoke produced byte-identical evidence-region +content (`9b018aa1483d3dba15cf5070e64dc4112e6ff4c6751f69cc463e2d0778889c25`). +Cold process wall time was 5.89-6.06 seconds; the bounded 16-tile inference stage +was 0.32-0.36 seconds, peak reserved VRAM was 122 MiB, and peak process RAM was +759.37-760.41 MiB. Tampered artifact metadata, a missing offline flag, and an +enabled analysis-network state all failed with no result artifact. + +These are runtime and protocol measurements on synthetic tissue-like pixels, +not pathology retrieval evidence. Patient/source-held-out breast, GI, lung, +lymph-node, benign/reactive, OOD, restart, and five-minute refinement gates have +not run. The model pack therefore remains default-off and ineligible for Viewer +evidence or staff/demo activation. Its executable candidate status is +`experimental`, while the frozen cross-tissue qualification verdict remains +`not_evaluable`. Embeddings remain local +and only bounded evidence regions may leave Forge after model qualification. + +## Real BRACS tile-cache smoke + +The frozen `bracs-roi-smoke-v1` cache contains seven patient-distinct validation +ROI samples and 28 deterministic 512 x 512 RGB PNG tiles. Its cohort manifest is +19,860,731 bytes including tiles and has SHA-256 +`4653fa49c1b488a7ab90f40a7722e802082a02dbeb967303b09078c4b2bbb2bb`. +Every source, sample manifest, tile manifest, tile pixel file, coordinate, and +revision is independently checksum-bound before inference. + +One real BRACS ROI worker smoke was repeated three times. Evidence-region content +was identical (`2e4f597caf50837bbf67de8179742a6c233a6160a723f7933adb8e5709860457`). +Cold wall time was 6.88-7.23 seconds, bounded four-tile model time was 0.19-0.23 +seconds, peak reserved VRAM was 122 MiB, and peak process RAM was 761.30-762.21 +MiB. A stale slide revision and an incorrect tile-manifest checksum both failed +without a result artifact. + +This remains a protocol smoke, not held-out retrieval qualification: BRACS ROI is +breast-only, the bounded cache is not a whole-slide cohort, and reference, GI, +lung, lymph-node, independent benign/reactive-source, and OOD coverage are +absent. The pre-registered protocol therefore returns `NOT_EVALUABLE` without +computing or relaxing model-performance thresholds. + +## NCT-CRC GI execution cohort + +The checksum-verified Zenodo 1214456 archives now provide a deterministic, +bounded GI execution cohort with 20 reference and 20 query patches from each +of the nine published tissue classes. Each selected 224-pixel patch is decoded +from the immutable archive and deterministically materialized as one +coordinate-bound 512-pixel RGB tile. Archive, entry, derived source, sample, +and tile-cache checksums are retained. + +This permits real GI execution and later retrieval-metric implementation, but +does not satisfy the pre-registered qualification protocol. The public patch +release does not expose a per-patch patient/slide map suitable for independently +checking patient overlap, and breast, lung, lymph-node, independent +benign/reactive-source, and OOD groups remain absent. These gaps are recorded as +`NOT_EVALUABLE`; no gate is lowered and no deployment eligibility is implied. + +## NCT-CRC GI retrieval execution + +The checksum-bound cohort worker now loads DINOv2 once, evaluates all 180 +reference and 180 query patches, compares exact rankings with the frozen +`color-histogram-v1` baseline, and repeats model inference to test exact ranking +agreement. It checkpoints with immutable per-batch records, reports 720 total +work units, validates every tile and provenance record under `LocalService`, and +emits aggregate metrics without raw pixels or embeddings. + +Campaign `dinov2-nct-crc-gi-retrieval-20260823-v3` completed all 720 units on +the P2000 with no retry and produced signed evidence SHA-256 +`824621fbe90f23af0cedc7d6038b7c3429177bbdffb448fc33c2c6efc530271c`. +The installed 2.1.1 service signed the conservative terminal verdict +`experimental`; it predates the report adapter that carries cohort metrics into +the signed qualification report. The scores therefore remain service-private +execution output until the tested runner update can be installed. Regardless of +those GI scores, the full H&E verdict cannot become qualified while patient +mapping, cross-tissue groups, and OOD fixtures remain absent. + +## TCGA lung retrieval execution + +Campaign `dinov2-tcga-lung-retrieval-20260823-v1` used 40 patient-distinct, +checksum-bound open GDC slides: TCGA-LUAD supplied ten normal and ten tumor +reference samples, and TCGA-LUSC supplied the corresponding query samples. The +offline P2000 worker completed 80/80 work units with zero retries. Signed +evidence SHA-256 is +`d0d14efa935870d3089e216b476c81f82c0f019c4c10abb6f55967201ad947e0`; +signed qualification report SHA-256 is +`a681fee3f13d14ae8a7fab46c5d1601f5e9d21b194bb26979eef26088ccab3e4`. + +DINOv2 improved macro Recall@5 over the identical-tile color-histogram +baseline by 0.25, but macro NDCG@10 improvement was 0.029096055, below the +pre-registered 0.03 threshold. Exact ranking repeatability, rights/integrity, +offline execution, and resource-envelope checks passed. OOD and full +cross-tissue gates remain not evaluable. The signed verdict is therefore +`experimental`; no threshold was rounded down or relaxed. + +## BRACS breast and benign/reactive retrieval + +Campaign `dinov2-bracs-breast-retrieval-20260824-v2-engineering-repair` +completed 160/160 offline P2000 work units over a checksum-bound 40-reference, +40-query patient-disjoint BRACS cohort. Exact rankings repeated, but DINOv2 +underperformed the identical-tile color-histogram baseline for Recall@5 by +`0.153741497` and improved NDCG@10 by only `0.005640305`, below the frozen +`0.03` requirement. BRACS is also a single source release and supplies no OOD +group. The signed verdict is `experimental`, and the candidate remains inactive. +Exact hashes and boundaries are recorded in +`docs/evidence/bracs-breast-dinov2-retrieval-result-20260824.md`. diff --git a/docs/model-cards/he-gigapath-benchmark-v1.md b/docs/model-cards/he-gigapath-benchmark-v1.md new file mode 100644 index 0000000..5b145a6 --- /dev/null +++ b/docs/model-cards/he-gigapath-benchmark-v1.md @@ -0,0 +1,10 @@ +# GigaPath benchmark-only pack + +Status: `not-evaluable`. No weights or executable worker are bundled. The +upstream deployment boundary excludes this candidate from OCI, Study Packs, and +staff/demo evidence. It may be considered only for a lawful local benchmark or +future teacher review after exact artifact, license, and derivative-rights +records are independently approved. + +Results from this pack must never satisfy pilot activation, evidence publication, +diagnostic, clinical-scoring, or treatment claims. diff --git a/docs/model-cards/he-hibou-b-v1.md b/docs/model-cards/he-hibou-b-v1.md new file mode 100644 index 0000000..bd3792e --- /dev/null +++ b/docs/model-cards/he-hibou-b-v1.md @@ -0,0 +1,6 @@ +# Hibou-B H&E candidate + +Status: `not-evaluable`. The user must personally accept upstream gated contact-sharing terms. +No credential or mutable remote code may enter analysis. Activation additionally requires an exact +revision, audited vendored model code, artifact hashes, rights review, held-out improvement and P2000 +latency qualification. diff --git a/docs/model-cards/ihc-descriptive-v1.md b/docs/model-cards/ihc-descriptive-v1.md new file mode 100644 index 0000000..43c5b32 --- /dev/null +++ b/docs/model-cards/ihc-descriptive-v1.md @@ -0,0 +1,21 @@ +# Deterministic H/DAB descriptive pack + +Status: `experimental`. The current CPU implementation provides bounded, +within-image DAB-like area and optical-density summaries, deterministic +optical-density watershed cell masks, marker-aware nuclear descriptors for ER, +PR, and Ki-67, membrane-associated descriptors for HER2, and reviewed-region +descriptors for PD-L1. Its stain separation is not yet independently validated, +and it does not claim calibrated cross-batch intensity or clinical scoring. + +Until independent marker-specific fixtures pass, every execution is +qualification-only. Marker-aware outputs remain descriptive research estimates. +PD-L1 requires reviewed compartment geometry, not only a compartment-source +label. Missing geometry must produce generic regional output and +`COMPARTMENT_REVIEW_REQUIRED`. + +The pack never emits positive/negative calls, ASCO/CAP categories, TPS, CPS, +diagnosis, prognosis, treatment guidance, or cross-slide cell correspondence. + +The executable synthetic protocol is documented in +`docs/evidence/brightfield-qualification-protocol-v1.md`. Synthetic generic-DAB +and fail-closed QC checks cannot replace independent marker-specific fixtures. diff --git a/docs/model-cards/tutor-qwen3-0.6b-int4-v1.md b/docs/model-cards/tutor-qwen3-0.6b-int4-v1.md new file mode 100644 index 0000000..1e0b3ff --- /dev/null +++ b/docs/model-cards/tutor-qwen3-0.6b-int4-v1.md @@ -0,0 +1,7 @@ +# Qwen3-0.6B grounded tutor candidate + +Status: `not-evaluable`. Upstream Qwen3-0.6B declares Apache-2.0, but the complete reviewed INT4 +ONNX WebGPU package has not met the frozen 500 MiB browser-asset cap. No model asset is bundled. +Any future candidate may return only validated knowledge-pack claim IDs and ordering; canonical +reviewed text and citations remain outside the model, with deterministic retrieval as fallback. + diff --git a/docs/plans/active/current.md b/docs/plans/active/current.md index 3ec5b45..6d0a43d 100644 --- a/docs/plans/active/current.md +++ b/docs/plans/active/current.md @@ -1,38 +1,386 @@ -# Active Task — Modular Capability Release +# Active Task — Autonomous Evidence Mentor Service -Supersedes the completed direct-OME release-candidate brief by explicit -product-owner direction on 2026-08-11. +Supersedes the modular-capability stop rule by explicit product-owner direction +on 2026-08-22. The research, licensing, privacy, and fail-closed boundaries remain. ## Fixed endpoint -Deliver a lightweight Forge base plus a signed, offline-by-default Feature -Center. The base owns WSI inspection, direct preview, annotations, geometry -measurements, conversion, validation and upload. Optional Pathology Tools and -Classical Analysis packs are downloaded only after an explicit user action. - -AI is not a core feature. Forge bundles no weights or training runtime. At most -one separately downloaded pretrained research model may be published after -license, provenance, resource and deterministic self-test gates pass. Training -Lab is a separate advanced pack using user-provided labelled data. - -## Milestones - -1. Remove redundant preview work and finish adaptive runtime limits. -2. Add signed catalog, staged installation, self-test, rollback and uninstall. -3. Add bounded raw-region, PathObject, measurement and analysis-run contracts. -4. Add the Pathology Tools pack boundary: hierarchy, H&E, stains and TMA. -5. Add the Classical Analysis pack boundary: tissue/cells/classifier/QC/registration. -6. Add the optional pretrained-AI and Training Lab availability gates. -7. Add explicit private Viewer synchronization with conflict-safe verification. - -## Stop rule - -Stop after these seven milestones and their local validation report. Do not add -third-party plugins, arbitrary scripting, a dependency solver, cloud services, -microservices, distributed workers, additional models, merge or deployment. - -New conversions use only the measured `ome-dynamic-v1` direct path. The governed -same-slide release-candidate comparison found it 46.67% faster and 72.96% smaller -than prepared-v2. Existing prepared artifacts remain readable, but Forge does not -select or retry into that larger route. Image-quality gates, artifact reuse, -privacy and resumable upload remain regression contracts. +Forge submits immutable local-analysis requests to the independently running +`PathLabEvidenceMentor` Windows service. The service owns the WAL queue, +checkpoint recovery, signed evidence packaging, and an operations-only loopback +dashboard. Forge, the dashboard, and Codex may close after a `202 Accepted` +response without interrupting work. + +The service uses a dynamic loopback port published through +`pathlab.runner-endpoint/1`, boot-scoped browser sessions, one GPU lane and one +low-priority CPU/I/O lane. Analysis is network-disabled. No raw pixels, +embeddings, patient identifiers, provisional regions, or scientific outcomes +appear in monitoring payloads. + +## Delivery gates + +1. Queue v2 migration, progress sidecars, renewable leases, retry classes, and pause control. +2. Dynamic endpoint discovery and authenticated standalone dashboard. +3. Forge compact status and one-time dashboard launcher. +4. Checksum-pinned WinSW packaging, ACLs, firewall policy, side-by-side rollback. +5. Actual LocalService, reboot, Session 0 P2000, offline, and recovery acceptance. +6. Independent expert-pack qualification for H&E, cells, IHC, special stains, and cytology. +7. Dual-lineage Atlas feasibility only after lawful frozen teachers exist. + +## Current status + +Gates 1-4 remain implemented. Service `2.1.0` was installed side-by-side on +2026-08-22 and passed the 19-check non-reboot acceptance, including a real +offline DINOv2 CUDA job on the P2000 across a service restart. An existing +byte-identical runtime is reused; a mismatch fails before the active +configuration changes. The manual reboot-continuation acceptance is still +deferred, so Gate 5 and activation remain incomplete. + +Runner `2.1.1` added fail-closed candidate preflight terminalization: immutable +candidate validation failures become `not_evaluable` rather than blocking the +parent coordinator, while database and filesystem I/O failures still surface. +The runtime starts and reports healthy locally, but the upgrade is not accepted: +this Windows host currently returns system error 2 when creating any outbound +firewall rule, including a control rule for a Windows executable. The installer +now stages replacement rules before removing old rules and restores the prior +runtime configuration on any setup or health failure. Model analysis must not +be activated until an outbound-deny rule is verifiably installed. + +Read-only DISM found a healthy component store, while SFC found and repaired +protected-file integrity violations. The active firewall provider still needs a +Windows restart before it can accept new rules. Claims are durably paused with +an empty queue. The one-time SYSTEM task `PathLabEvidenceMentorPostRepair` is +prepared to retry the transactional upgrade two minutes after startup, verify +exact outbound-deny coverage and service acceptance, and resume claims only on +PASS. It records a durable result and removes itself after success; a failure +remains paused and fail-closed. + +The product owner deferred that restart on 2026-08-22 so real research work can +continue while the PC stays on. This is an explicit degraded-environment waiver, +not firewall acceptance: new model results remain experimental and cannot enter +the qualified registry, learner evidence, deployment activation, or a scientific +claim until outbound-deny and restart-continuation acceptance pass. + +Real GI data acquisition is now active as an interactive-user, resumable task. +It is downloading the immutable Zenodo record 1214456 NCT-CRC H&E reference and +validation archives (12,490,560,932 bytes total), pinned to the official file +sizes and MD5 values under CC BY 4.0. Completion additionally records SHA-256 +values and a source ledger. The task enforces the 45 GB new-source quota and +runs independently of Forge, Codex, and the analysis service. The dashboard +status contract exposes only bounded acquisition metadata; analysis remains +credential-free and network-disabled. These patch archives add a real GI +external evaluation source, but do not by themselves prove patient-held-out +qualification when patient identity metadata is unavailable. + +Runner `2.1.2` is the next staged package. It adds the bounded acquisition +status feed and dashboard rendering without changing the paused analysis state. +It must not replace the installed runtime until the same transactional firewall +and health checks can succeed; source download completion does not depend on +that service upgrade. + +The clean NCT-CRC transfer was restarted after the Zenodo endpoint ignored HTTP +resume and produced an oversized checksum-invalid partial. The invalid file is +preserved under acquisition quarantine and cannot enter the source ledger. The +worker now performs at most three clean attempts, captures the real curl exit +code, rejects size/checksum mismatches, and never requests byte-range resume. + +An installed-state audit on 2026-08-23 found and repaired two operational +bookkeeping defects without restarting the service: the dashboard launcher now +passes one-time fragments through an explicit Edge/Chrome process, and the +active-version marker now agrees with the running 2.1.1 XML, endpoint, and Java +child. The hidden SYSTEM post-reboot task was restored and verified `Ready` by +an elevated repair receipt. Future failed upgrades derive rollback identity from +the actual prior XML rather than a possibly stale marker. Runtime 2.1.2 is built +and locally tested but remains uninstalled until outbound firewall creation can +pass after the deferred reboot. + +The branch now implements `pathlab.qualification-campaign/1` as a durable +parent coordinator above the existing GPU and CPU/I/O lanes. It records frozen +dependencies, attempts, signed attestations, completion versus target success, +and a signed capability matrix. One bounded remediation is enforced. Candidate +manifests cannot activate themselves: v2 execution requires an exact trusted +registry entry binding pack, artifacts, protocol, and qualification attestation. +Evidence and qualification signatures are verified against ACL-protected local +trust registries rather than payload-embedded keys. + +Gate 6 now includes executable deterministic baselines for true optical-density +watershed instance masks and morphometry, marker-aware nuclear ER/PR/Ki-67 +descriptors, HER2 membrane descriptors, PD-L1 reviewed-compartment enforcement, +generic H/DAB output, PAS/PAS-D, trichrome, GMS, AFB, and Papanicolaou +descriptors. The four deterministic v2 candidate manifests are pinned to +pre-registered protocol files. Evidence v2 and signed multi-pack evidence-set +fusion preserve coordinates, QC, provenance, and uncertainty and explicitly +forbid serial-section cell matching. These implementations are not qualified: +rights-cleared held-out reference sets and the frozen quantitative gates still +must run. Gated DINOv2/Hibou/GigaPath, HoVer-Net/PathoSAM, Qwen assets, and public +cytology data are not represented as acquired or built when their exact +artifacts or rights are absent. + +The checksum-frozen `deterministic-baselines-20260822-v1` campaign completed +autonomously against a grandfathered, patient-disjoint BRACS test input. Cell, +IHC, special-stain, and cytology execution tracks each produced a signed +`experimental` attestation. `campaignCompleted=true` and +`campaignTargetMet=false`: this proves durable execution and signing, not the +pre-registered held-out quality gates. The reusable staging command is +`scripts/stage-local-deterministic-campaign.ps1`. + +The signed `all-rounder-candidate-preflight-20260822-v1` campaign also completed +autonomously with `campaignCompleted=true` and `campaignTargetMet=false`. +DINOv2, Hibou-B, GigaPath, HoVer-Net, PathoSAM, Qwen, and Atlas each reached the +honest terminal status `not_evaluable`; no candidate job or model inference was +launched. The result is an availability/readiness record, not a scientific +qualification. Its reusable staging command is +`scripts/stage-all-rounder-candidate-preflight.ps1`. + +Gate 7 has an executable four-hour feasibility decision contract using +checkpointed 15-20 minute segments, fixed RAM/VRAM limits, validation +improvement, seven-day projection, 24 GiB rental minimum, and USD 100 ceiling. +No Atlas weights have been trained. The campaign must return +`LOCAL_INSUFFICIENT`/`not_evaluable` or pause for an exact user-approved quote +when lawful teachers, data, checkpoints, or hardware evidence are missing. + +Implementation, service installation, model qualification, merge, deployment, +feature activation, and any future clinical/capacity study are separate gates. +Feature flags remain default-off and no diagnostic or clinical-scoring claims +are permitted. + +On 2026-08-23 the NCT-CRC acquisition reached `completed`: both Zenodo 1214456 +archives passed their official MD5 values, independent local SHA-256 hashing, +and source-ledger publication. The bounded +`nct-crc-gi-20-per-class-v1` cohort then materialized 360 deterministic +coordinate-bound samples (20 reference and 20 query patches for each of nine +published tissue classes) without expanding the full archives. Its cohort +manifest SHA-256 is +`e3fb44a4e977aeb99a1a5d0a6a97fcd8495fc14b24e66ac126e8205594bdbb38`. +The public release does not provide a per-patch patient map suitable for an +independent overlap audit, and the other frozen tissue/OOD groups remain +absent, so the full H&E gate remains `NOT_EVALUABLE`. + +The autonomous `dinov2-nct-crc-gi-execution-20260823-v1` campaign executed the +pinned DINOv2-small worker against one real checksum-bound GI query patch on +the GPU lane with offline analysis enforced and zero retries. It produced +signed evidence SHA-256 +`2ea00e772d27da090e5799fdd11bafe69924ca238dea9190c9e816ad0f4ce2a6` +and a signed terminal `experimental` attestation manifest SHA-256 +`f69abdcafc68bdafcf707177a6a2a83a5b61f8c7f100e69d4bcd40b77473c6e3`. +`campaignCompleted=true` and `campaignTargetMet=false`: this is real-data +execution evidence, not cohort retrieval performance or deployment +qualification. The next engineering gate is a cohort worker that loads the +model once and computes the frozen color-histogram comparison, retrieval, +repeatability, and OOD metrics without exporting embeddings. + +That cohort worker is now implemented and exercised. Immutable checkpoint names +closed a progress/checkpoint race found by the runner during acceptance, and +campaign `dinov2-nct-crc-gi-retrieval-20260823-v3` subsequently completed all +720 work units (two deterministic passes over 360 samples) on the GPU lane with +zero retry. Signed evidence SHA-256 is +`824621fbe90f23af0cedc7d6038b7c3429177bbdffb448fc33c2c6efc530271c` and the +signed attestation SHA-256 is +`3f63f446f3fde3647681918d699fa141852f0526133290644f1028669d8d40dc`. +The installed 2.1.1 report adapter conservatively records `experimental` and +does not expose the worker's aggregate cohort metrics; the branch now contains +the strict signed-report adapter for the next service package. Full H&E remains +`NOT_EVALUABLE` because patient mapping, breast, lung, lymph-node, +benign/reactive-source, and OOD coverage are still missing. + +An elevated 2.1.3 packaging attempt exposed an exact version mismatch: its +authenticated endpoint reported application version 2.1.2. No lung +qualification was launched. The corrected runner, side-by-side runtime, and +launcher are versioned 2.1.4 so installed runtime identity and endpoint version +must agree before campaign submission. + +The bounded `tcga-luad-lusc-he-20x2-v1` acquisition completed autonomously in +the interactive acquisition context. Its immutable GDC manifest +contains 40 patient-distinct open-access SVS files: ten normal and ten tumor +slides from TCGA-LUAD for the reference split, and the same counts from +TCGA-LUSC for the query split. The total frozen transfer is 720,405,670 bytes. +Every completed source is checked against the GDC MD5 and then independently +SHA-256 ledgered; the offline service receives no credentials or network +access. After completion, `build-tcga-lung-dinov2-cohort.ps1` selects a +tissue-bearing coordinate from a local overview, extracts an immutable 512 px +tile, and freezes the lung cohort. The 2.1.4-only +`stage-tcga-lung-dinov2-retrieval.ps1` then submits its two-pass qualification +campaign with an explicit cohort path and checksum. + +The acquisition completed all 720,405,670 bytes and ledgered 40 checksum-bound, +patient-distinct slides. The initial interactive cohort build correctly failed +against the service-only derived-data ACL. The repaired launcher now pauses new +service claims, obtains the runner's quota snapshot, grants a temporary bounded +build ACE, restores the exact prior SDDL in `finally`, and resumes claims. The +resulting 40-sample lung cohort is immutable at SHA-256 +`7bf027c6740360ec00172088d965b230442641cd041aedc1eccb7b75dc195566`. + +Runner 2.1.4 subsequently installed with matching endpoint identity and active +outbound-deny rules. Campaign `dinov2-tcga-lung-retrieval-20260823-v1` +completed 80/80 GPU work units with zero retries. Signed evidence SHA-256 is +`d0d14efa935870d3089e216b476c81f82c0f019c4c10abb6f55967201ad947e0` +and signed report SHA-256 is +`a681fee3f13d14ae8a7fab46c5d1601f5e9d21b194bb26979eef26088ccab3e4`. +Recall@5 improvement passed at 0.25; NDCG@10 improvement was 0.029096055 and +missed the frozen 0.03 threshold. Repeatability, rights/integrity, offline, and +resource checks passed; OOD and full cross-tissue coverage remain unavailable. +The honest terminal verdict is `experimental`, with +`campaignCompleted=true` and `campaignTargetMet=false`. + +The single pre-registered, label-blind lung tile-selection remediation then +completed as campaign `dinov2-tcga-lung-qc-remediation-20260823-v1`. It used a +frozen 40-sample cohort at SHA-256 +`daaf335ba67d3cf189355b73d7d4a4081566d6518685ec4d7cb912fa013779de`, +finished 80/80 GPU work units with zero retries, and produced signed evidence +SHA-256 +`ff084d41f1da82868cd550d7a8aa555032d5a5b9d040d2d8358c07d57c797b3d` +and signed attestation SHA-256 +`142710f8ef70137e98cdebfa99c9f987a54e63818dc481d5632cd0837a1628e4`. +The unchanged retrieval gates passed: Recall@5 improvement was `0.15` and +NDCG@10 improvement was `0.149220098`, with exact ranking repeatability. +The track remains `experimental`, `campaignCompleted=true`, and +`campaignTargetMet=false` because frozen OOD fixtures and complete cross-tissue +patient/source-held-out coverage remain unavailable. The one remediation is +consumed; no additional lung tuning is permitted under this protocol. + +The executable brightfield harness now confirms that the optical-density, +distance-transform watershed passes its bounded synthetic separated/touching +nuclei, deterministic-repeat, and reviewed-region RLE-mask checks. Generic DAB, +relative-only calibration, and weak-separation refusal checks also pass. +Cell qualification remains `experimental` pending rights-cleared cross-tissue +held-out annotations. Marker-specific IHC and PD-L1 compartment tracks remain +`not_evaluable` pending independent fixtures and reviewed compartment geometry; +synthetic behavior is not promoted into scientific qualification. + +For the next IHC execution gate, the pinned 1,311,155-byte metadata bundle from +TumorQuantAI Zenodo record `21797920` passed its official MD5 and local SHA-256 +checks. A four-case-disjoint subset of the smallest complete archives is frozen +at 421,890,082 bytes and is downloading through the autonomous interactive-user +acquisition task `PathLabTumorQuantIhcAcquisition`. The dataset is CC BY 4.0, +but it supplies research measurement proxies rather than independent clinical +ground truth. Completion can support descriptive ER/PR/Ki-67/HER2 integration +fixtures only; it cannot qualify clinical scoring, PD-L1, serial-section cell +matching, or either Atlas teacher lineage without a separate derivative review. +The initial full-stream transfer repeatedly lost partial progress when Zenodo +closed long responses. The repaired worker checkpoints exact 8 MiB HTTP range +chunks, retries each chunk at most three times, accepts only exact byte ranges, +and validates the assembled archive against the publisher's frozen SHA-256. +Task Scheduler no longer restarts an exhausted three-attempt transfer. A second +autonomous task, `PathLabTumorQuantIhcFixtureCampaign`, waits for acquisition, +then builds bounded case-disjoint 512 px ER/PR/Ki-67/HER2 fixtures and submits +the four-track research-only campaign. Both stages expose operational progress +on the dashboard and neither depends on Forge, the browser, or Codex remaining +open. + +The bounded TumorQuantAI source acquisition subsequently completed all +421,890,082 bytes and published four checksum-verified, case-disjoint archives. +The first fixture campaign preserved a useful fail-closed engineering result: +all four requests rejected stale `.partial` paths after the atomic fixture-root +rename, so no model analysis ran. Immutable fixture set v2 resolves only bounded +relative paths after rename and validates every final file checksum while the +temporary derived-data ACE is active. Campaign +`tumorquantai-ihc-descriptive-execution-20260823-v2` then completed four offline +CPU/I/O tracks for ER, PR, Ki-67, and HER2 with signed `experimental` +attestations and no retry. Its fixture manifest SHA-256 is +`a46be25af796580897679d417ce2e3379c349d9b5a2d2b5d4b9337a01e828145` +and campaign manifest SHA-256 is +`d50676d11a55ce6b045511cf6aadc81bd783949bf8a4905e1a06879f4afdf77f`. +`campaignCompleted=true` and `campaignTargetMet=false`: executable marker-aware +descriptive behavior is proven on real source material, but the independent +held-out quantitative reference gates remain `not_evaluable`. No clinical +scoring or activation follows from this execution result. + +CAMELYON lymph-node acquisition remains `NOT_EVALUABLE` and has not started. +The two official CAMELYON17 pages currently expose conflicting reuse language, +so `docs/evidence/lymph-node-source-rights-review-20260823.md` freezes the exact +blocker and required remediation. No gate is lowered and no ambiguous data may +enter evidence, Study Packs, OCI, or either Atlas lineage. + +The next cell-instance gate uses the official MoNuSAC 2020 training, testing, +and supplementary artifacts (767,901,963 bytes total). Exact organizer Drive +IDs, filenames, response sizes, and CC BY-NC-SA 4.0 lineage are frozen in +`acquire-monusac2020.ps1`. The autonomous interactive-user task +`PathLabMonusacAcquisition` downloads exact 8 MiB ranges with bounded retry and +publishes local SHA-256 values. Because the organizer provides no upstream +cryptographic checksums, acquisition completion is preparation only: it remains +restricted, Atlas-Clean-ineligible, and `not_evaluable` until archive metadata, +patient/slide grouping, masks, and integrity corroboration are frozen. + +MoNuSAC acquisition and cohort preparation subsequently completed. The frozen +23-patient, four-organ held-out cohort is SHA-256 +`63885be5c1e271669acf9debaba2419b0b8cddeb6a7e5bf102d751e55a9727c8`; +the two published train/test patient overlaps were excluded. Campaign +`monusac-od-watershed-heldout-20260824-v1` completed with a signed +`experimental` attestation and `campaignTargetMet=false`. Macro PQ, instance +Dice, count error, and morphometry gates failed decisively while deterministic +repeat, four-tissue coverage, and failed-region rate passed. + +The first evaluator also exceeded the cell pack's resource envelope and made +the operations API temporarily unresponsive. The bounded-memory replacement +reproduced the exact metrics locally in 7.32 seconds with 395.94 MiB peak heap. +Runner `2.1.6` passed the complete build, but two UAC prompts to install it were +declined, so the installed service remains `2.1.5` and the one unchanged-gate +signed remediation campaign is pending. Watershed remains a fail-closed +fallback; it is not qualified or activated. Exact results and hashes are in +`docs/evidence/monusac-cell-qualification-result-20260824.md`. + +The official HoVer-Net code at revision +`67e2ce5e3f1a64a2ece77ad1c24233653a9e0901` and the 150,995,854-byte fast +MoNuSAC checkpoint are now locally acquired and checksum-frozen. Code and +weight rights remain separate: the code is MIT, while the checkpoint stays in +the MoNuSAC `CC-BY-NC-SA-4.0` restricted research lineage and is not +Atlas-Clean eligible. Safe CPU checkpoint loading succeeded in the shared +PyTorch 2.7.1+cu126 runtime, but the original post-processing dependencies are +absent. The candidate remains `not_evaluable` and inactive until a pinned +compatibility overlay or reviewed minimal adapter passes offline P2000 +inference and the unchanged 23-patient held-out gates. Exact hashes and the +remaining boundary are recorded in +`docs/evidence/hovernet-candidate-acquisition-result-20260824.md`. + +The checksum-pinned compatibility adapter then reached real P2000 execution as +`cell-hovernet-fast-monusac-v1/4`. It references the existing PyTorch +2.7.1+cu126 runtime instead of copying or mutating it, and adds a locally +manifested SciPy 1.18.1 overlay. Two offline synthetic probes produced the same +72 instance detections and research-category counts using 532 MiB peak VRAM +and approximately 1.1 GiB peak RAM. This is synthetic runtime conformance only, +not MoNuSAC held-out accuracy or qualification. The pack remains inactive and +`not_evaluable`; service integration, immutable progress/checkpoints, and the +unchanged 23-patient gates remain. Exact hashes and measurements are in +`docs/evidence/hovernet-runtime-probe-result-20260824.md`. + +The branch runner now validates `pathlab.model-runtime-reference/1` before +launching a shared-runtime worker: exact state-root identities, canonical-path +containment, the complete shared runtime file ledger, candidate ledger, weight, +worker, and reference hashes must all pass. It also validates bounded +`pathlab.cell-instance-metrics/1` results and supplies deterministic CuBLAS +configuration. This is source-ready infrastructure only. The installed 2.1.5 +service is unchanged, cell jobs are not yet routed to the external worker, and +the HoVer-Net worker still lacks durable cohort/checkpoint execution. Details +are in `docs/evidence/hovernet-runner-integration-status-20260824.md`. + +The first HoVer-Net held-out campaign then completed autonomously under service +`2.1.7`. It reached an honest signed `experimental` verdict, not qualification: +all frozen accuracy, count, morphometry, and failed-region gates failed, while +determinism, four-tissue coverage, and the resource envelope passed. The +dashboard stopped at 22/23 because the worker wrote its final sidecar immediately +before exiting, and the first result retained only a failure count rather than +bounded sample-level failure codes. Runner `2.1.8` and pack version `7` repair +both observability defects without changing the model, cohort, or gates. The +real P2000 probe, full tests, repository policy verification, and exact-head +Linux/macOS/Windows CI pass. Service `2.1.8` is installed and the single +unchanged-gate remediation campaign completed at an accurately reported 23/23 +units with zero retry. Its signed verdict remains `experimental` and +`campaignTargetMet=false`; the unchanged model produced the same failing +held-out metrics. The bounded worker result now retains three sample failure +records, but the signed v2 report exposes only their aggregate integrity +outcome, so exact sample failure codes remain an operator-reporting limitation. +The candidate remains inactive. Exact results and boundaries are in +`docs/evidence/hovernet-cohort-worker-status-20260824.md`. + +The next independent H&E track used the grandfathered BRACS ROI collection to +freeze 40 validation-reference and 40 test-query samples across breast and +benign/reactive groups. Campaign +`dinov2-bracs-breast-retrieval-20260824-v2-engineering-repair` completed 160/160 +offline GPU units with zero retry and exact ranking repeatability. Cohort rights, +checksums, patient-split isolation, and resource gates passed. DINOv2 failed the +unchanged retrieval comparison: Recall@5 improvement was `-0.153741497`, and +NDCG@10 improvement was `0.005640305` versus the required `0.03`. BRACS is also +a single source and supplies no OOD group. The signed verdict is `experimental`, +`campaignTargetMet=false`, and the pack remains inactive. Exact hashes and the +first-attempt engineering failure are in +`docs/evidence/bracs-breast-dinov2-retrieval-result-20260824.md`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f3cbb9..3baed19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,6 +53,7 @@ const DEFAULT_LOCAL_FOLDER = 'Unfiled' export function App() { const [datasets, setDatasets] = useState([]) const [capabilities, setCapabilities] = useState>>() + const [evidenceStatus, setEvidenceStatus] = useState() const [selectedId, setSelectedId] = useState('') const [seriesByDataset, setSeriesByDataset] = useState>({}) const [artifactByDataset, setArtifactByDataset] = useState>({}) @@ -189,6 +190,17 @@ export function App() { .catch((nextError) => setError(message(nextError))) }, []) + useEffect(() => { + const update = () => void api.evidenceRunnerStatus() + .then(setEvidenceStatus) + .catch(() => setEvidenceStatus({ + schema: 'pathlab.evidence-runner-status/2', status: 'unavailable', detail: 'Runner is not reachable', + })) + update() + const timer = window.setInterval(update, 5_000) + return () => window.clearInterval(timer) + }, []) + useEffect(() => { if (!datasets.some((item) => ACTIVE_STATUSES.has(item.status))) return const timer = window.setInterval(() => void refresh(), 1500) @@ -798,6 +810,30 @@ export function App() { )} /> +
+ Evidence Mentor + {evidenceStatus?.status === 'ready' + ? `${evidenceStatus.queue?.active || 0} active · ${evidenceStatus.queue?.queued || 0} queued` + : 'Runner unavailable'} + {evidenceStatus?.status === 'ready' ? + {evidenceStatus.acceptingJobs === false ? 'New claims paused' : 'Analysis network off'} + {evidenceStatus.leadingJob + ? ` · ${evidenceStatus.leadingJob.id} ${Math.round(evidenceStatus.leadingJob.progress * 100)}%` + : ''} + {evidenceStatus.heartbeat ? ` · heartbeat ${new Date(evidenceStatus.heartbeat).toLocaleTimeString()}` : ''} + : {evidenceStatus?.detail || 'Checking local runner…'}} + {evidenceStatus?.status === 'ready' ? : null} +
{connection?.connected && viewerUpload?.viewerSlideId && ['IMAGE_READY', 'SYNCING_RESULTS', 'COMPLETE'].includes(viewerUpload.state) ? ( ('/api/capabilities') } +export type EvidenceRunnerStatus = { + schema: 'pathlab.evidence-runner-status/2' + status: 'ready' | 'unavailable' + detail?: string + serviceVersion?: string + startedAt?: string + heartbeat?: string + acceptingJobs?: boolean + queue?: { queued: number; active: number; gpu: number; cpuIo: number } + leadingJob?: { id: string; state: string; stage: string; progress: number; lane: 'gpu' | 'cpu_io' } + networkDisabledForAnalysis?: boolean + processMemoryUsedBytes?: number + processMemoryLimitBytes?: number + vramLimitMiB?: number + diskUsableBytes?: number + updatedAt?: string + quota?: Record +} + +export async function evidenceRunnerStatus() { + return request('/api/evidence/status') +} + +export async function openEvidenceDashboard() { + return request<{ url: string }>('/api/evidence/dashboard', { method: 'POST' }) +} + export async function importProjectFolder(path?: string) { const query = path?.trim() ? `?path=${encodeURIComponent(path.trim())}` : '' return request<{ datasets: Dataset[]; project?: { root: string; imported: number; failed: string } }>( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e1014ba..760ec99 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -6,6 +6,7 @@ button,input,select{font:inherit} button,a{-webkit-tap-highlight-color:transparent} button{cursor:pointer} .visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +.evidence-runner-dashboard{position:fixed;right:18px;bottom:70px;z-index:40;display:grid;gap:2px;max-width:420px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--surface-elevated);box-shadow:0 8px 30px rgba(35,29,24,.14);color:var(--body);font-size:12px}.evidence-runner-dashboard span{font-weight:650}.evidence-runner-dashboard small{color:var(--muted)}.evidence-runner-dashboard.ready{border-color:var(--success)} .forge-canvas-host,.forge-canvas-host>.pathlab-canvas-shell{width:100%;height:100%;min-height:0}.forge-canvas-host>.pathlab-canvas-shell{height:100dvh;grid-template:"rail navigator stage inspector" minmax(0,1fr) "rail queue queue queue" 58px/var(--library-rail-width) minmax(220px,270px) minmax(0,1fr) minmax(280px,380px)}.forge-canvas-host>.pathlab-canvas-shell.rail-expanded{--library-rail-width:176px}.forge-canvas-host.inspector-collapsed>.pathlab-canvas-shell{grid-template:"rail navigator stage" minmax(0,1fr) "rail queue queue" 58px/var(--library-rail-width) minmax(220px,270px) minmax(0,1fr)}.forge-canvas-host.navigator-collapsed>.pathlab-canvas-shell{grid-template:"rail stage inspector" minmax(0,1fr) "rail queue queue" 58px/var(--library-rail-width) minmax(0,1fr) minmax(280px,380px)}.forge-canvas-host.navigator-collapsed.inspector-collapsed>.pathlab-canvas-shell{grid-template:"rail stage" minmax(0,1fr) "rail queue" 58px/var(--library-rail-width) minmax(0,1fr)}.pathlab-viewer-inspector{min-height:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable} .library-app-rail{width:var(--library-rail-width);height:100dvh;position:sticky;top:0;z-index:50;display:flex;flex-direction:column;align-items:center;gap:4px;overflow:hidden auto;padding:6px 4px;border-right:1px solid var(--border);background:var(--surface)} .library-rail-brand{width:38px;height:38px;display:grid;flex:0 0 auto;place-items:center} diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index 41c8502..fd8f3e7 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -40,6 +40,14 @@ vi.mock('../SlideViewer', () => ({ })) vi.mock('../api', () => ({ + evidenceRunnerStatus: vi.fn(async () => ({ + schema: 'pathlab.evidence-runner-status/2', + status: 'unavailable', + detail: 'Runner is not configured in frontend tests', + })), + openEvidenceDashboard: vi.fn(async () => ({ + url: 'http://127.0.0.1:43210/dashboard/#single-use', + })), bootstrap: vi.fn(async () => [[], { conversionRuntime: 'Bio-Formats test', derivativeRuntime: 'libvips test', diff --git a/qualification-protocols/cell-hovernet-fast-monusac-v1.json b/qualification-protocols/cell-hovernet-fast-monusac-v1.json new file mode 100644 index 0000000..67ad319 --- /dev/null +++ b/qualification-protocols/cell-hovernet-fast-monusac-v1.json @@ -0,0 +1 @@ +{"schema":"pathlab.qualification-protocol/1","id":"cell-hovernet-fast-monusac-v1","gates":{"macroPqMinimum":0.45,"instanceDiceMinimum":0.70,"countErrorMaximum":0.15,"morphometryBiasMaximum":0.10,"failedRegionRateMaximum":0.05,"deterministicRepeatRequired":true},"split":"patient-source-held-out","researchOnly":true,"weightLineage":"CC-BY-NC-SA-4.0-restricted"} diff --git a/qualification-protocols/cell-od-watershed-v2.json b/qualification-protocols/cell-od-watershed-v2.json new file mode 100644 index 0000000..87d2e43 --- /dev/null +++ b/qualification-protocols/cell-od-watershed-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.qualification-protocol/1","id":"cell-od-watershed-v2","gates":{"macroPqMinimum":0.45,"instanceDiceMinimum":0.70,"countErrorMaximum":0.15,"morphometryBiasMaximum":0.10,"failedRegionRateMaximum":0.05,"deterministicRepeatRequired":true},"split":"patient-source-held-out","researchOnly":true} diff --git a/qualification-protocols/cytology-descriptive-v2.json b/qualification-protocols/cytology-descriptive-v2.json new file mode 100644 index 0000000..3b90bcb --- /dev/null +++ b/qualification-protocols/cytology-descriptive-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.qualification-protocol/1","id":"cytology-descriptive-v2","gates":{"cellAccuracyRequired":true,"overlapAbstentionRequired":true,"insufficientTissueAbstentionRequired":true,"clinicalIdentityForbidden":true},"tracks":["papanicolaou"],"researchOnly":true} diff --git a/qualification-protocols/ihc-descriptive-v2.json b/qualification-protocols/ihc-descriptive-v2.json new file mode 100644 index 0000000..1cf94be --- /dev/null +++ b/qualification-protocols/ihc-descriptive-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.qualification-protocol/1","id":"ihc-descriptive-v2","gates":{"proportionErrorPointsMaximum":3,"intensityOdErrorMaximum":0.05,"qcFixturesRequired":true,"pdl1ReviewedCompartmentRequired":true,"clinicalScoresForbidden":true},"tracks":["generic","er","pr","ki-67","her2","pd-l1"],"researchOnly":true} diff --git a/qualification-protocols/special-stain-descriptive-v2.json b/qualification-protocols/special-stain-descriptive-v2.json new file mode 100644 index 0000000..a33e754 --- /dev/null +++ b/qualification-protocols/special-stain-descriptive-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.qualification-protocol/1","id":"special-stain-descriptive-v2","gates":{"proportionErrorPointsMaximum":3,"intensityOdErrorMaximum":0.05,"qcFixturesRequired":true,"genericFallbackRequired":true},"tracks":["pas","pas_d","trichrome","gms","afb"],"researchOnly":true} diff --git a/scripts/acquire-dinov2-small.ps1 b/scripts/acquire-dinov2-small.ps1 new file mode 100644 index 0000000..f97452d --- /dev/null +++ b/scripts/acquire-dinov2-small.ps1 @@ -0,0 +1,152 @@ +[CmdletBinding()] +param( + [string]$StateRoot +) + +$ErrorActionPreference = 'Stop' +$repoId = 'facebook/dinov2-small' +$revision = 'ed25f3a31f01632728cabb09d1542f84ab7b0056' +$packId = 'he-dinov2-small-v1' +$packVersion = '1' +$modelQuotaBytes = 10GB +$files = @( + [pscustomobject]@{ + Name = 'config.json' + Bytes = 547L + Sha256 = '1809f83e3bdb1609a501a610ad4a742f4fd8ae44d72ca4aa0df52d1f2ac8628d' + }, + [pscustomobject]@{ + Name = 'preprocessor_config.json' + Bytes = 436L + Sha256 = '14e780d86fa1861f8751f868d7f45425b5feb55c38ca26f152ca5097ab30f828' + }, + [pscustomobject]@{ + Name = 'model.safetensors' + Bytes = 88249960L + Sha256 = 'ae1e99fcefd534ed978cdeb8326f08030c96e28b7a81ffcbc98a857c84d14be1' + } +) + +if (-not $StateRoot) { + $StateRoot = if (Test-Path -LiteralPath 'D:\PathLabData' -PathType Container) { + 'D:\PathLabData\EvidenceMentor\state' + } else { + Join-Path $env:LOCALAPPDATA 'PathLab\EvidenceMentor\state' + } +} + +$state = [IO.Path]::GetFullPath($StateRoot) +$modelsRoot = [IO.Path]::GetFullPath((Join-Path $state 'models')) +$installRoot = [IO.Path]::GetFullPath((Join-Path $modelsRoot "$packId\$packVersion")) +if (-not $installRoot.StartsWith($modelsRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'DINOv2 install path escaped the Evidence Mentor model root.' +} + +$reservationRoot = Join-Path $state 'quota\reservations\models' +New-Item -ItemType Directory -Path $installRoot -Force | Out-Null +New-Item -ItemType Directory -Path $reservationRoot -Force | Out-Null + +function Get-TreeBytes([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = 0L + Get-ChildItem -LiteralPath $Path -Recurse -File | ForEach-Object { + $sum = [long]($sum + $_.Length) + } + return $sum +} + +$requiredBytes = 0L +foreach ($file in $files) { + $target = Join-Path $installRoot $file.Name + if (Test-Path -LiteralPath $target -PathType Leaf) { + $existing = Get-Item -LiteralPath $target + $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + if ($existing.Length -ne $file.Bytes -or $existingHash -ne $file.Sha256) { + throw "Existing DINOv2 artifact failed checksum: $($file.Name)" + } + } else { + $requiredBytes = [long]($requiredBytes + $file.Bytes) + } +} + +$existingReceiptPath = Join-Path $installRoot 'acquisition.json' +if ($requiredBytes -eq 0 -and (Test-Path -LiteralPath $existingReceiptPath -PathType Leaf)) { + $existingReceipt = Get-Content -Raw -LiteralPath $existingReceiptPath | ConvertFrom-Json + if ($existingReceipt.schema -ne 'pathlab.model-acquisition/1' -or + $existingReceipt.repository -ne $repoId -or $existingReceipt.revision -ne $revision) { + throw 'Existing DINOv2 acquisition receipt is invalid.' + } + Write-Output "Already acquired $repoId@$revision under $installRoot" + Write-Output 'Acquisition never activates the pack; inspect its separate runtime and held-out qualification records.' + return +} + +$reservedBytes = 0L +Get-ChildItem -LiteralPath $reservationRoot -Filter '*.reservation' -File | ForEach-Object { + $value = 0L + if (-not [long]::TryParse(([IO.File]::ReadAllText($_.FullName).Trim()), [ref]$value) -or $value -lt 0) { + throw "Invalid model quota reservation: $($_.Name)" + } + $reservedBytes = [long]($reservedBytes + $value) +} +$usedBytes = Get-TreeBytes $modelsRoot +if ($usedBytes -gt $modelQuotaBytes -or $reservedBytes -gt ($modelQuotaBytes - $usedBytes) -or + $requiredBytes -gt ($modelQuotaBytes - $usedBytes - $reservedBytes)) { + throw 'Evidence model quota is exhausted.' +} + +$reservation = Join-Path $reservationRoot "acquire-$packId-$packVersion.reservation" +$reservationPartial = "$reservation.partial" +if ((Test-Path -LiteralPath $reservation) -or (Test-Path -LiteralPath $reservationPartial)) { + throw 'A DINOv2 acquisition reservation already exists; inspect it before retrying.' +} +[IO.File]::WriteAllText($reservationPartial, [string]$requiredBytes) +Move-Item -LiteralPath $reservationPartial -Destination $reservation + +try { + foreach ($file in $files) { + $target = Join-Path $installRoot $file.Name + if (Test-Path -LiteralPath $target -PathType Leaf) { continue } + $partial = "$target.partial" + if (Test-Path -LiteralPath $partial) { + throw "Partial DINOv2 artifact requires review before retry: $partial" + } + $uri = "https://huggingface.co/$repoId/resolve/$revision/$($file.Name)" + Invoke-WebRequest -Uri $uri -OutFile $partial -MaximumRedirection 10 + $download = Get-Item -LiteralPath $partial + $hash = (Get-FileHash -LiteralPath $partial -Algorithm SHA256).Hash.ToLowerInvariant() + if ($download.Length -ne $file.Bytes -or $hash -ne $file.Sha256) { + throw "Downloaded DINOv2 artifact failed checksum: $($file.Name)" + } + Move-Item -LiteralPath $partial -Destination $target + } + + $receipt = [ordered]@{ + schema = 'pathlab.model-acquisition/1' + packId = $packId + packVersion = $packVersion + repository = $repoId + revision = $revision + license = 'Apache-2.0' + permittedUse = 'private-research' + acquiredAt = [DateTimeOffset]::UtcNow.ToString('O') + artifacts = @($files | ForEach-Object { + [ordered]@{ name = $_.Name; bytes = $_.Bytes; sha256 = $_.Sha256 } + }) + analysisNetwork = 'disabled' + activationStatus = 'not-evaluable-worker-not-installed' + } + $receiptPath = Join-Path $installRoot 'acquisition.json' + $receiptPartial = "$receiptPath.partial" + [IO.File]::WriteAllText($receiptPartial, (($receipt | ConvertTo-Json -Depth 5) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $receiptPartial -Destination $receiptPath -Force +} finally { + if (Test-Path -LiteralPath $reservation -PathType Leaf) { + Remove-Item -LiteralPath $reservation -Force + } +} + +Write-Output "Acquired $repoId@$revision under $installRoot" +Write-Output 'Acquisition never activates the pack; inspect its separate runtime and held-out qualification records.' diff --git a/scripts/acquire-hovernet-candidate.ps1 b/scripts/acquire-hovernet-candidate.ps1 new file mode 100644 index 0000000..acfb2cd --- /dev/null +++ b/scripts/acquire-hovernet-candidate.ps1 @@ -0,0 +1,277 @@ +[CmdletBinding()] +param( + [ValidateSet('Start','Run','Status')] [string] $Action = 'Start', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$candidateId = 'hovernet-fast-monusac-v1' +$candidateVersion = '1' +$taskName = 'PathLabHoverNetAcquisition' +$modelLimit = 10GB +$reservationBytes = 512MB +$repository = 'https://github.com/vqdang/hover_net.git' +$commit = '67e2ce5e3f1a64a2ece77ad1c24233653a9e0901' +$readmeUrl = "https://raw.githubusercontent.com/vqdang/hover_net/$commit/README.md" +$licenseUrl = "https://raw.githubusercontent.com/vqdang/hover_net/$commit/LICENSE" +$codeArchiveUrl = "https://codeload.github.com/vqdang/hover_net/zip/$commit" +$weightDriveId = '13qkxDqv7CUqxN-l5CpeFVmc24mDw6CeV' +$weightName = 'hovernet_fast_monusac_type_tf2pytorch.tar' +$weightBytes = 150995854L +$chunkBytes = 8MB +$state = [IO.Path]::GetFullPath($StateRoot) +$modelsRoot = [IO.Path]::GetFullPath((Join-Path $state 'models')) +$candidateRoot = [IO.Path]::GetFullPath((Join-Path $modelsRoot "$candidateId\$candidateVersion")) +$stagingRoot = [IO.Path]::GetFullPath((Join-Path $modelsRoot ".partial-$candidateId-$candidateVersion")) +$acquisitionRoot = Join-Path $state "acquisition\$candidateId" +$statusPath = Join-Path $acquisitionRoot 'status.json' +$reservationPath = Join-Path $modelsRoot ".acquisition-reservations\$candidateId.reservation" +$runtimeManifest = Join-Path $modelsRoot 'he-dinov2-small-v1\1\runtime-manifest.json' + +if (-not $candidateRoot.StartsWith($modelsRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) -or + -not $stagingRoot.StartsWith($modelsRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'HoVer-Net candidate path escaped the Evidence Mentor model root.' +} + +function Write-JsonAtomic([string] $Path, [object] $Value) { + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} + +function Write-Status([string] $StateValue, [long] $Completed, [string] $Detail) { + Write-JsonAtomic $statusPath ([ordered]@{ + schema='pathlab.acquisition-status/1';datasetId=$candidateId;state=$StateValue + completedBytes=$Completed;totalBytes=$weightBytes;detail=$Detail + networkContext='interactive-user-acquisition-only';analysisNetwork='disabled' + updatedAt=[DateTimeOffset]::UtcNow.ToString('o') + }) +} + +function Directory-Bytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = 0L + Get-ChildItem -LiteralPath $Path -File -Recurse -ErrorAction Stop | ForEach-Object { + $sum = [long]($sum + $_.Length) + } + return $sum +} + +function Get-DownloadedBytes { + $published = Join-Path $candidateRoot $weightName + if (Test-Path -LiteralPath $published -PathType Leaf) { + return [Math]::Min([long](Get-Item -LiteralPath $published).Length, $weightBytes) + } + $chunkRoot = Join-Path $stagingRoot '.weight-chunks' + return [Math]::Min((Directory-Bytes $chunkRoot), $weightBytes) +} + +function Finish-Task { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} + +if ($Action -eq 'Status') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { Get-Content -LiteralPath $statusPath -Raw } + else { '{"schema":"pathlab.acquisition-status/1","state":"not_started"}' } + return +} + +if (Test-Path -LiteralPath (Join-Path $candidateRoot 'candidate-ledger.json') -PathType Leaf) { + $ledger = Get-Content -LiteralPath (Join-Path $candidateRoot 'candidate-ledger.json') -Raw | ConvertFrom-Json + if ($ledger.schema -ne 'pathlab.model-candidate-acquisition/1' -or + $ledger.candidateId -ne $candidateId -or $ledger.code.commit -ne $commit) { + throw 'Existing HoVer-Net candidate ledger is incompatible.' + } + $existingCode = Join-Path $candidateRoot ([string]$ledger.code.archive) + $existingWeight = Join-Path $candidateRoot ([string]$ledger.weights.fileName) + if (-not (Test-Path -LiteralPath $existingCode -PathType Leaf) -or + -not (Test-Path -LiteralPath $existingWeight -PathType Leaf) -or + (Get-Item -LiteralPath $existingWeight).Length -ne $weightBytes -or + (Get-FileHash -LiteralPath $existingCode -Algorithm SHA256).Hash.ToLowerInvariant() -ne [string]$ledger.code.sha256 -or + (Get-FileHash -LiteralPath $existingWeight -Algorithm SHA256).Hash.ToLowerInvariant() -ne [string]$ledger.weights.sha256) { + throw 'Existing HoVer-Net candidate artifact checksum changed.' + } + Write-Status 'completed' $weightBytes 'Restricted HoVer-Net candidate is already checksum-frozen; qualification remains not_evaluable.' + return +} + +New-Item -ItemType Directory -Path $acquisitionRoot -Force | Out-Null +New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null +$reservationRoot = Split-Path -Parent $reservationPath +New-Item -ItemType Directory -Path $reservationRoot -Force | Out-Null + +if ($Action -eq 'Start') { + $used = Directory-Bytes $modelsRoot + $reserved = 0L + Get-ChildItem -LiteralPath $reservationRoot -Filter '*.reservation' -File -ErrorAction SilentlyContinue | + Where-Object FullName -ne $reservationPath | + ForEach-Object { $reserved += [long]([IO.File]::ReadAllText($_.FullName).Trim()) } + if ($reservationBytes -gt $modelLimit - $used - $reserved) { + throw 'The 10 GB model quota cannot reserve the HoVer-Net candidate.' + } + if (-not (Test-Path -LiteralPath $reservationPath -PathType Leaf)) { + [IO.File]::WriteAllText("$reservationPath.partial", [string]$reservationBytes) + Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath + } + + $headers = @{'User-Agent'='PathLab-Forge/2.1 research acquisition'} + $readmePath = Join-Path $stagingRoot 'UPSTREAM-README.md' + $licensePath = Join-Path $stagingRoot 'UPSTREAM-LICENSE' + Invoke-WebRequest -Uri $readmeUrl -OutFile "$readmePath.partial" -Headers $headers -TimeoutSec 60 + Move-Item -LiteralPath "$readmePath.partial" -Destination $readmePath -Force + Invoke-WebRequest -Uri $licenseUrl -OutFile "$licensePath.partial" -Headers $headers -TimeoutSec 60 + Move-Item -LiteralPath "$licensePath.partial" -Destination $licensePath -Force + $readme = Get-Content -LiteralPath $readmePath -Raw + $license = Get-Content -LiteralPath $licensePath -Raw + if (-not $readme.Contains($weightDriveId) -or $readme -notmatch 'MoNuSAC checkpoint') { + throw 'The pinned official HoVer-Net README no longer identifies the expected MoNuSAC fast checkpoint.' + } + if ($license -notmatch 'MIT License') { throw 'The pinned HoVer-Net code license changed.' } + + Write-JsonAtomic (Join-Path $stagingRoot 'rights-review.json') ([ordered]@{ + schema='pathlab.model-rights-review/1';candidateId=$candidateId + repository=$repository;commit=$commit + codeLicense='MIT';weightLicense='CC-BY-NC-SA-4.0' + codeAndWeightLicensesReviewedSeparately=$true + permittedUse='private-research-restricted';upstreamChecksumAvailable=$false + integrityPolicy='source-integrity-frozen-after-first-acquisition' + atlasResearchEligible=$true;atlasCleanEligible=$false + qualificationStatus='not_evaluable' + reason='RUNTIME_ADAPTATION_AND_HELD_OUT_EXECUTION_PENDING' + reviewedAt=[DateTimeOffset]::UtcNow.ToString('o') + }) + + $durable = Join-Path $acquisitionRoot 'acquire-hovernet-candidate.ps1' + Copy-Item -LiteralPath $PSCommandPath -Destination $durable -Force + $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$durable`" -Action Run -StateRoot `"$state`"" + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) + $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Days 2) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Principal $principal -Settings $settings -Description 'Acquire the official restricted HoVer-Net fast MoNuSAC research candidate.' -Force | Out-Null + Write-Status 'queued' (Get-DownloadedBytes) 'Official code and weight rights verified; checksum-frozen acquisition scheduled.' + Start-ScheduledTask $taskName + Get-Content -LiteralPath $statusPath -Raw + return +} + +$curl = (Get-Command curl.exe -ErrorAction Stop).Source +try { + $codeArchive = Join-Path $stagingRoot "hover_net-$commit.zip" + if (-not (Test-Path -LiteralPath $codeArchive -PathType Leaf)) { + Write-Status 'transferring' (Get-DownloadedBytes) 'Downloading the exact official HoVer-Net code revision.' + Remove-Item -LiteralPath "$codeArchive.partial" -Force -ErrorAction SilentlyContinue + & $curl '--fail' '--location' '--silent' '--show-error' '--retry' '3' '--max-time' '300' '--user-agent' 'PathLab-Forge/2.1 research acquisition' '--output' "$codeArchive.partial" $codeArchiveUrl + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath "$codeArchive.partial" -PathType Leaf) -or + (Get-Item -LiteralPath "$codeArchive.partial").Length -eq 0) { + throw 'The exact HoVer-Net code revision download failed.' + } + Move-Item -LiteralPath "$codeArchive.partial" -Destination $codeArchive -Force + } + $codeSha256 = (Get-FileHash -LiteralPath $codeArchive -Algorithm SHA256).Hash.ToLowerInvariant() + + $weightPartial = Join-Path $stagingRoot "$weightName.partial" + $weightFinal = Join-Path $stagingRoot $weightName + $chunkRoot = Join-Path $stagingRoot '.weight-chunks' + New-Item -ItemType Directory -Path $chunkRoot -Force | Out-Null + if (-not (Test-Path -LiteralPath $weightFinal -PathType Leaf)) { + Remove-Item -LiteralPath $weightPartial -Force -ErrorAction SilentlyContinue + $url = "https://drive.usercontent.google.com/download?id=$weightDriveId&export=download&confirm=t" + $chunkIndex = 0 + for ($offset = 0L; $offset -lt $weightBytes; $offset += $chunkBytes) { + $end = [Math]::Min($weightBytes - 1, $offset + $chunkBytes - 1) + $expectedChunkBytes = $end - $offset + 1 + $chunk = Join-Path $chunkRoot ('{0:D5}.part' -f $chunkIndex) + if (Test-Path -LiteralPath $chunk -PathType Leaf) { + if ((Get-Item -LiteralPath $chunk).Length -eq $expectedChunkBytes) { $chunkIndex++; continue } + Remove-Item -LiteralPath $chunk -Force + } + for ($attempt = 1; $attempt -le 3; $attempt++) { + Write-Status 'transferring' (Get-DownloadedBytes) "Downloading HoVer-Net weight chunk $($chunkIndex + 1) (attempt $attempt of 3)." + $headerPath = "$chunk.headers.partial" + Remove-Item -LiteralPath $headerPath -Force -ErrorAction SilentlyContinue + $process = Start-Process $curl -ArgumentList @('--fail','--location','--silent','--show-error','--remove-on-error','--range',"$offset-$end",'--dump-header',('"'+$headerPath+'"'),'--output',('"'+$chunk+'.partial"'),$url) -PassThru -NoNewWindow + while (-not $process.HasExited) { + Write-Status 'transferring' (Get-DownloadedBytes) "Downloading HoVer-Net weight chunk $($chunkIndex + 1) (attempt $attempt of 3)." + Start-Sleep -Seconds 15 + $process.Refresh() + } + $process.WaitForExit() + $expectedContentRange = "bytes $offset-$end/$weightBytes" + $hasExpectedContentRange = (Test-Path -LiteralPath $headerPath -PathType Leaf) -and + ((Get-Content -LiteralPath $headerPath -Raw) -match + ('(?im)^Content-Range:\s*' + [Regex]::Escape($expectedContentRange) + '\s*$')) + if ($hasExpectedContentRange -and (Test-Path -LiteralPath "$chunk.partial" -PathType Leaf) -and + (Get-Item -LiteralPath "$chunk.partial").Length -eq $expectedChunkBytes) { + Move-Item -LiteralPath "$chunk.partial" -Destination $chunk -Force + Remove-Item -LiteralPath $headerPath -Force -ErrorAction SilentlyContinue + break + } + Remove-Item -LiteralPath "$chunk.partial" -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $headerPath -Force -ErrorAction SilentlyContinue + if ($attempt -lt 3) { Start-Sleep -Seconds $(if ($attempt -eq 1) { 5 } else { 30 }) } + } + if (-not (Test-Path -LiteralPath $chunk -PathType Leaf) -or + (Get-Item -LiteralPath $chunk).Length -ne $expectedChunkBytes) { + throw "HoVer-Net weight chunk transfer failed: bytes $offset-$end" + } + $chunkIndex++ + } + + $output = [IO.File]::Open($weightPartial, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + Get-ChildItem -LiteralPath $chunkRoot -Filter '*.part' -File | Sort-Object Name | ForEach-Object { + $input = [IO.File]::OpenRead($_.FullName) + try { $input.CopyTo($output) } finally { $input.Dispose() } + } + } finally { $output.Dispose() } + if ((Get-Item -LiteralPath $weightPartial).Length -ne $weightBytes) { + throw 'HoVer-Net weight assembled size mismatch.' + } + $weightSha256 = (Get-FileHash $weightPartial -Algorithm SHA256).Hash.ToLowerInvariant() + Move-Item -LiteralPath $weightPartial -Destination $weightFinal -Force + Remove-Item -LiteralPath $chunkRoot -Recurse -Force + } else { + if ((Get-Item -LiteralPath $weightFinal).Length -ne $weightBytes) { + throw 'Existing staged HoVer-Net weight size changed.' + } + $weightSha256 = (Get-FileHash -LiteralPath $weightFinal -Algorithm SHA256).Hash.ToLowerInvariant() + } + + if (-not (Test-Path -LiteralPath $runtimeManifest -PathType Leaf)) { + throw 'The checksum-pinned shared CUDA runtime candidate is unavailable.' + } + $runtimeSha256 = (Get-FileHash -LiteralPath $runtimeManifest -Algorithm SHA256).Hash.ToLowerInvariant() + $actualBytes = Directory-Bytes $stagingRoot + $otherModelBytes = (Directory-Bytes $modelsRoot) - $actualBytes + if ($actualBytes -gt $reservationBytes -or $actualBytes -gt $modelLimit - $otherModelBytes) { + throw 'The acquired HoVer-Net candidate exceeded its model quota reservation.' + } + + Write-JsonAtomic (Join-Path $stagingRoot 'candidate-ledger.json') ([ordered]@{ + schema='pathlab.model-candidate-acquisition/1';candidateId=$candidateId;version=$candidateVersion + capability='cell-instance-detection';mode='fast';typeInfo='monusac-research-categories' + code=[ordered]@{repository=$repository;commit=$commit;archive=(Split-Path -Leaf $codeArchive);sha256=$codeSha256;license='MIT'} + weights=[ordered]@{fileName=$weightName;driveId=$weightDriveId;bytes=$weightBytes;sha256=$weightSha256;license='CC-BY-NC-SA-4.0';upstreamChecksumAvailable=$false} + codeAndWeightLicensesReviewedSeparately=$true + permittedUse='private-research-restricted';atlasResearchEligible=$true;atlasCleanEligible=$false + sharedRuntimeCandidate='he-dinov2-small-v1/1';sharedRuntimeManifestSha256=$runtimeSha256 + runtimeCopiedIntoCandidate=$false;runtimeCompatibilityStatus='not_evaluable' + qualificationStatus='not_evaluable';qualificationReason='RUNTIME_ADAPTATION_AND_HELD_OUT_EXECUTION_PENDING' + analysisNetwork='disabled';activationEligible=$false + frozenAt=[DateTimeOffset]::UtcNow.ToString('o') + }) + Write-Status 'validating' $weightBytes 'Publishing the immutable restricted-research candidate ledger.' + New-Item -ItemType Directory -Path (Split-Path -Parent $candidateRoot) -Force | Out-Null + Move-Item -LiteralPath $stagingRoot -Destination $candidateRoot + Remove-Item -LiteralPath $reservationPath -Force + Write-Status 'completed' $weightBytes 'Official code and MoNuSAC fast weights are checksum-frozen; runtime adaptation and qualification remain not_evaluable.' + Finish-Task +} catch { + Write-Status 'failed' (Get-DownloadedBytes) $_.Exception.Message + Finish-Task + throw +} diff --git a/scripts/acquire-monusac2020.ps1 b/scripts/acquire-monusac2020.ps1 new file mode 100644 index 0000000..9f716c3 --- /dev/null +++ b/scripts/acquire-monusac2020.ps1 @@ -0,0 +1,251 @@ +[CmdletBinding()] +param( + [ValidateSet('Start','Run','Status')] [string] $Action = 'Start', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$datasetId = 'monusac2020-official-v1' +$taskName = 'PathLabMonusacAcquisition' +$sourceLimit = 45GB +$sourcePage = 'https://monusac-2020.grand-challenge.org/Data/' +$expected = @( + [ordered]@{kind='training';driveId='1lxMZaAPSpEHLSxGA9KKMt_r-4S8dwLhq';name='MoNuSAC_images_and_annotations.zip';bytes=545564883L}, + [ordered]@{kind='testing';driveId='1G54vsOdxWY1hG7dzmkeK3r0xz9s-heyQ';name='MoNuSAC Testing Data and Annotations.zip';bytes=202746703L}, + [ordered]@{kind='supplement';driveId='1kdOl3s6uQBRv0nToSIf1dPuceZunzL4N';name='MoNuSAC_Supplementary_material.pdf';bytes=19590377L} +) +$requiredBytes = 0L +foreach ($item in $expected) { $requiredBytes += [long]$item.bytes } +$state = [IO.Path]::GetFullPath($StateRoot) +$root = Join-Path $state "acquisition\$datasetId" +$downloadRoot = Join-Path $root 'downloads' +$sourceRoot = Join-Path $state "sources\$datasetId" +$statusPath = Join-Path $root 'status.json' +$sourcePagePath = Join-Path $root 'official-data-page.html' +$rightsPath = Join-Path $root 'rights-and-integrity.json' +$ledgerPath = Join-Path $sourceRoot 'sample-ledger.jsonl' +$integrityPath = Join-Path $sourceRoot 'local-integrity.json' +$reservationPath = Join-Path $state "quota\reservations\source\$datasetId.reservation" + +function Write-JsonAtomic([string] $Path, [object] $Value) { + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} + +function Write-Status([string] $StateValue, [long] $Completed, [string] $Detail) { + Write-JsonAtomic $statusPath ([ordered]@{ + schema='pathlab.acquisition-status/1';datasetId=$datasetId;state=$StateValue + completedBytes=$Completed;totalBytes=$requiredBytes;detail=$Detail + networkContext='interactive-user-acquisition-only';analysisNetwork='disabled' + updatedAt=[DateTimeOffset]::UtcNow.ToString('o') + }) +} + +function Directory-Bytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = Get-ChildItem -LiteralPath $Path -File -Recurse | Measure-Object Length -Sum + if ($null -eq $sum -or $null -eq $sum.Sum) { return 0L } + return [long]$sum.Sum +} + +function Get-DownloadedBytes { + $total = 0L + foreach ($item in $expected) { + $published = Join-Path $sourceRoot $item.name + if (Test-Path -LiteralPath $published -PathType Leaf) { + $total += [Math]::Min([long](Get-Item -LiteralPath $published).Length, [long]$item.bytes) + continue + } + $target = Join-Path $downloadRoot $item.name + if (Test-Path -LiteralPath $target -PathType Leaf) { + $total += [Math]::Min([long](Get-Item -LiteralPath $target).Length, [long]$item.bytes) + continue + } + $chunkRoot = Join-Path $downloadRoot ".chunks\$($item.kind)" + $total += [Math]::Min((Directory-Bytes $chunkRoot), [long]$item.bytes) + } + return $total +} + +function Finish-Task { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} + +if ($Action -eq 'Status') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { Get-Content $statusPath -Raw } + else { '{"schema":"pathlab.acquisition-status/1","state":"not_started"}' } + return +} + +New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null +$reservationRoot = Split-Path -Parent $reservationPath +if (-not (Test-Path -LiteralPath $reservationRoot -PathType Container)) { + throw 'The service-managed source quota reservation root is unavailable.' +} + +if ($Action -eq 'Start') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { + $prior = Get-Content $statusPath -Raw | ConvertFrom-Json + if ($prior.state -eq 'completed') { Get-Content $statusPath -Raw; return } + } + + $used = Directory-Bytes (Join-Path $state 'sources') + $reserved = 0L + Get-ChildItem $reservationRoot -Filter '*.reservation' -File -ErrorAction SilentlyContinue | + Where-Object FullName -ne $reservationPath | + ForEach-Object { $reserved += [long]([IO.File]::ReadAllText($_.FullName).Trim()) } + if ($requiredBytes -gt $sourceLimit - $used - $reserved) { + throw 'The 45 GB source quota cannot reserve the official MoNuSAC artifacts.' + } + if (-not (Test-Path -LiteralPath $reservationPath)) { + [IO.File]::WriteAllText("$reservationPath.partial", [string]$requiredBytes) + Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath + } + + $page = Invoke-WebRequest -Uri $sourcePage -UseBasicParsing -TimeoutSec 60 -Headers @{'User-Agent'='PathLab-Forge/2.1 research acquisition'} + [IO.File]::WriteAllText("$sourcePagePath.partial", [string]$page.Content, [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$sourcePagePath.partial" -Destination $sourcePagePath -Force + $pageText = [string]$page.Content + if ($pageText -notmatch 'CC BY-NC-SA 4.0') { throw 'The official MoNuSAC license statement is unavailable or changed.' } + foreach ($item in $expected) { + if (-not $pageText.Contains([string]$item.driveId)) { throw "The official MoNuSAC link changed: $($item.kind)" } + } + $pageSha = (Get-FileHash $sourcePagePath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-JsonAtomic $rightsPath ([ordered]@{ + schema='pathlab.source-rights-review/1';datasetId=$datasetId;source=$sourcePage + sourcePageSha256=$pageSha;license='CC-BY-NC-SA-4.0' + permittedUse='private-research-restricted';upstreamChecksumAvailable=$false + integrityPolicy='source-integrity-frozen-after-first-acquisition' + atlasResearchEligible=$true;atlasCleanEligible=$false + qualificationStatus='not_evaluable';reason='OFFICIAL_UPSTREAM_CHECKSUM_NOT_PUBLISHED' + reviewedAt=[DateTimeOffset]::UtcNow.ToString('o') + }) + + $durable = Join-Path $root 'acquire-monusac2020.ps1' + Copy-Item -LiteralPath $PSCommandPath -Destination $durable -Force + $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$durable`" -Action Run -StateRoot `"$state`"" + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) + $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Days 7) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Principal $principal -Settings $settings -Description 'Acquire official restricted MoNuSAC cell-instance evaluation artifacts.' -Force | Out-Null + Write-Status 'queued' (Get-DownloadedBytes) 'Official links and restricted-use rights verified; acquisition scheduled.' + Start-ScheduledTask $taskName + Get-Content $statusPath -Raw + return +} + +$curl = (Get-Command curl.exe -ErrorAction Stop).Source +$chunkBytes = 8MB +$integrity = [Collections.Generic.List[object]]::new() +try { + foreach ($item in $expected) { + $published = Join-Path $sourceRoot $item.name + if (Test-Path -LiteralPath $published -PathType Leaf) { + if ((Get-Item -LiteralPath $published).Length -ne [long]$item.bytes) { throw "Published MoNuSAC artifact size changed: $($item.name)" } + $publishedSha = (Get-FileHash $published -Algorithm SHA256).Hash.ToLowerInvariant() + $integrity.Add([ordered]@{kind=$item.kind;name=$item.name;driveId=$item.driveId;bytes=$item.bytes;sha256=$publishedSha}) + continue + } + + $target = Join-Path $downloadRoot $item.name + $partial = "$target.partial" + Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue + $chunkRoot = Join-Path $downloadRoot ".chunks\$($item.kind)" + New-Item -ItemType Directory -Path $chunkRoot -Force | Out-Null + $url = "https://drive.usercontent.google.com/download?id=$($item.driveId)&export=download&confirm=t" + $chunkIndex = 0 + for ($offset = 0L; $offset -lt [long]$item.bytes; $offset += $chunkBytes) { + $end = [Math]::Min([long]$item.bytes - 1, $offset + $chunkBytes - 1) + $expectedChunkBytes = $end - $offset + 1 + $chunk = Join-Path $chunkRoot ('{0:D5}.part' -f $chunkIndex) + if (Test-Path -LiteralPath $chunk) { + if ((Get-Item -LiteralPath $chunk).Length -eq $expectedChunkBytes) { $chunkIndex++; continue } + Remove-Item -LiteralPath $chunk -Force + } + if ((Test-Path -LiteralPath "$chunk.partial") -and (Get-Item -LiteralPath "$chunk.partial").Length -eq $expectedChunkBytes) { + Move-Item -LiteralPath "$chunk.partial" -Destination $chunk -Force + $chunkIndex++ + continue + } + for ($attempt = 1; $attempt -le 3; $attempt++) { + Write-Status 'transferring' (Get-DownloadedBytes) "Downloading $($item.kind) chunk $($chunkIndex + 1) (attempt $attempt of 3)." + $headerPath = "$chunk.headers.partial" + Remove-Item -LiteralPath $headerPath -Force -ErrorAction SilentlyContinue + $process = Start-Process $curl -ArgumentList @('--fail','--location','--silent','--show-error','--remove-on-error','--range',"$offset-$end",'--dump-header',('"'+$headerPath+'"'),'--output',('"'+$chunk+'.partial"'),$url) -PassThru -NoNewWindow + while (-not $process.HasExited) { + Write-Status 'transferring' (Get-DownloadedBytes) "Downloading $($item.kind) chunk $($chunkIndex + 1) (attempt $attempt of 3)." + Start-Sleep -Seconds 15 + $process.Refresh() + } + $process.WaitForExit() + $expectedContentRange = "bytes $offset-$end/$($item.bytes)" + $hasExpectedContentRange = (Test-Path -LiteralPath $headerPath -PathType Leaf) -and + ((Get-Content -LiteralPath $headerPath -Raw) -match + ('(?im)^Content-Range:\s*' + [Regex]::Escape($expectedContentRange) + '\s*$')) + if ($hasExpectedContentRange -and (Test-Path -LiteralPath "$chunk.partial") -and + (Get-Item -LiteralPath "$chunk.partial").Length -eq $expectedChunkBytes) { + Move-Item -LiteralPath "$chunk.partial" -Destination $chunk -Force + Remove-Item -LiteralPath $headerPath -Force -ErrorAction SilentlyContinue + break + } + Remove-Item -LiteralPath "$chunk.partial" -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $headerPath -Force -ErrorAction SilentlyContinue + if ($attempt -lt 3) { Start-Sleep -Seconds $(if ($attempt -eq 1) { 5 } else { 30 }) } + } + if (-not (Test-Path -LiteralPath $chunk) -or (Get-Item -LiteralPath $chunk).Length -ne $expectedChunkBytes) { + throw "MoNuSAC chunk transfer failed: $($item.name) bytes $offset-$end" + } + $chunkIndex++ + } + + $output = [IO.File]::Open($partial, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + Get-ChildItem -LiteralPath $chunkRoot -Filter '*.part' -File | Sort-Object Name | ForEach-Object { + $input = [IO.File]::OpenRead($_.FullName) + try { $input.CopyTo($output) } finally { $input.Dispose() } + } + } finally { $output.Dispose() } + if ((Get-Item -LiteralPath $partial).Length -ne [long]$item.bytes) { throw "MoNuSAC assembled size mismatch: $($item.name)" } + $sha256 = (Get-FileHash $partial -Algorithm SHA256).Hash.ToLowerInvariant() + Move-Item -LiteralPath $partial -Destination $target -Force + Remove-Item -LiteralPath $chunkRoot -Recurse -Force + $integrity.Add([ordered]@{kind=$item.kind;name=$item.name;driveId=$item.driveId;bytes=$item.bytes;sha256=$sha256}) + } +} catch { + Write-Status 'failed' (Get-DownloadedBytes) $_.Exception.Message + Finish-Task + throw +} + +Write-Status 'validating' $requiredBytes 'Publishing locally frozen SHA-256 values and restricted source ledger.' +New-Item -ItemType Directory -Path $sourceRoot -Force | Out-Null +$lines = [Collections.Generic.List[string]]::new() +foreach ($item in $expected) { + $record = @($integrity | Where-Object name -eq $item.name) + if ($record.Count -ne 1) { throw "Local MoNuSAC integrity record missing: $($item.name)" } + $from = Join-Path $downloadRoot $item.name + $to = Join-Path $sourceRoot $item.name + if (Test-Path -LiteralPath $from -PathType Leaf) { Move-Item -LiteralPath $from -Destination $to -Force } + $lines.Add(([ordered]@{ + sampleId="monusac-$($item.kind)-archive";source=$sourcePage;patientGroup='archive-metadata-pending' + slideGroup='archive-metadata-pending';driveId=$item.driveId;fileName=$item.name + sha256=$record[0].sha256;bytes=$item.bytes;upstreamChecksumAvailable=$false + license='CC-BY-NC-SA-4.0';permittedUse='private-research-restricted' + task='cell-instance-qualification-preparation';split='unassigned';atlasCleanEligible=$false + } | ConvertTo-Json -Compress)) +} +[IO.File]::WriteAllLines($ledgerPath, $lines, [Text.UTF8Encoding]::new($false)) +Write-JsonAtomic $integrityPath ([ordered]@{ + schema='pathlab.local-source-integrity/1';datasetId=$datasetId + integrityPolicy='source-integrity-frozen-after-first-acquisition' + upstreamChecksumAvailable=$false;artifacts=$integrity + frozenAt=[DateTimeOffset]::UtcNow.ToString('o') +}) +Copy-Item -LiteralPath $sourcePagePath -Destination (Join-Path $sourceRoot 'official-data-page.html') -Force +Copy-Item -LiteralPath $rightsPath -Destination (Join-Path $sourceRoot 'rights-and-integrity.json') -Force +Remove-Item -LiteralPath $reservationPath -Force +Write-Status 'completed' $requiredBytes 'Official archives acquired and locally SHA-256 frozen; cell qualification remains not_evaluable pending metadata extraction and upstream-integrity corroboration.' +Finish-Task diff --git a/scripts/acquire-nct-crc-he.ps1 b/scripts/acquire-nct-crc-he.ps1 new file mode 100644 index 0000000..efbb683 --- /dev/null +++ b/scripts/acquire-nct-crc-he.ps1 @@ -0,0 +1,247 @@ +[CmdletBinding()] +param( + [ValidateSet('Start','Run','Status')] [string] $Action = 'Start', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$recordId = '1214456' +$datasetId = 'nct-crc-he-1214456' +$jobName = 'PathLab-NCT-CRC-HE-1214456' +$taskName = 'PathLabNctCrcAcquisition' +$sourceLimit = 45GB +$expected = [ordered]@{ + 'NCT-CRC-HE-100K.zip' = @{ bytes = 11690284003L; md5 = '6fd702d11df6292bc054397ae038a464' } + 'CRC-VAL-HE-7K.zip' = @{ bytes = 800276929L; md5 = '2fd1651b4f94ebd818ebf90ad2b6ce06' } +} +$requiredBytes = 0L +foreach ($item in $expected.Values) { $requiredBytes += [long]$item.bytes } +$state = [IO.Path]::GetFullPath($StateRoot) +$acquisitionRoot = Join-Path $state "acquisition\$datasetId" +$downloadRoot = Join-Path $acquisitionRoot 'downloads' +$sourceRoot = Join-Path $state "sources\$datasetId" +$statusPath = Join-Path $acquisitionRoot 'status.json' +$recordPath = Join-Path $acquisitionRoot 'zenodo-record.json' +$ledgerPath = Join-Path $sourceRoot 'sample-ledger.jsonl' +$reservationPath = Join-Path $state "quota\reservations\source\$datasetId.reservation" + +function Write-JsonAtomic([string] $Path, [object] $Value) { + $parent = Split-Path -Parent $Path + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, (($Value | ConvertTo-Json -Depth 16) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path -Force +} + +function Write-Status([string] $State, [long] $Completed, [long] $Total, [string] $Detail) { + Write-JsonAtomic $statusPath ([ordered]@{ + schema = 'pathlab.acquisition-status/1'; datasetId = $datasetId; state = $State + completedBytes = $Completed; totalBytes = $Total; detail = $Detail + networkContext = 'interactive-user-acquisition-only'; analysisNetwork = 'disabled' + updatedAt = [DateTimeOffset]::UtcNow.ToString('o') + }) +} + +function Directory-Bytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $measure = Get-ChildItem -LiteralPath $Path -File -Recurse -ErrorAction Stop | + Measure-Object -Property Length -Sum + if ($null -eq $measure -or $null -eq $measure.Sum) { return 0L } + return [long]$measure.Sum +} + +function Remove-Finalizer { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} + +function Get-DownloadedBytes { + $completed = 0L + foreach ($name in $expected.Keys) { + foreach ($candidate in @((Join-Path $downloadRoot $name), (Join-Path $downloadRoot "$name.partial"))) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $completed += [Math]::Min([long](Get-Item -LiteralPath $candidate).Length, + [long]$expected[$name].bytes) + break + } + } + } + return $completed +} + +function Move-ToQuarantine([string] $Path, [string] $Reason) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $resolved = [IO.Path]::GetFullPath($Path) + $resolvedDownloadRoot = [IO.Path]::GetFullPath($downloadRoot) + if (-not $resolved.StartsWith($resolvedDownloadRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to quarantine a file outside the acquisition download root.' + } + $quarantineRoot = Join-Path $downloadRoot 'quarantine' + New-Item -ItemType Directory -Path $quarantineRoot -Force | Out-Null + $stamp = [DateTimeOffset]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + $target = Join-Path $quarantineRoot "$([IO.Path]::GetFileName($Path)).corrupt-$stamp" + Move-Item -LiteralPath $resolved -Destination $target + Write-JsonAtomic "$target.json" ([ordered]@{ + schema = 'pathlab.acquisition-quarantine/1' + datasetId = $datasetId + fileName = [IO.Path]::GetFileName($Path) + bytes = [long](Get-Item -LiteralPath $target).Length + reason = $Reason + quarantinedAt = [DateTimeOffset]::UtcNow.ToString('o') + }) +} + +if ($Action -eq 'Status') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { Get-Content -LiteralPath $statusPath -Raw } + else { '{"schema":"pathlab.acquisition-status/1","state":"not_started"}' } + return +} + +foreach ($directory in @($acquisitionRoot, $downloadRoot, (Split-Path -Parent $reservationPath))) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } +} + +if ($Action -eq 'Start') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { + $prior = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json + if ($prior.state -eq 'completed') { $prior | ConvertTo-Json -Depth 8; return } + } + $used = Directory-Bytes (Join-Path $state 'sources') + $reserved = 0L + Get-ChildItem -LiteralPath (Split-Path -Parent $reservationPath) -Filter '*.reservation' -File ` + -ErrorAction SilentlyContinue | Where-Object FullName -ne $reservationPath | ForEach-Object { + $reserved += [long]([IO.File]::ReadAllText($_.FullName).Trim()) + } + if ($requiredBytes -gt $sourceLimit - $used - $reserved) { throw 'The 45 GB source quota cannot reserve NCT-CRC.' } + if (-not (Test-Path -LiteralPath $reservationPath -PathType Leaf)) { + [IO.File]::WriteAllText("$reservationPath.partial", [string]$requiredBytes, + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath + } + + $record = Invoke-RestMethod -Uri "https://zenodo.org/api/records/$recordId" -TimeoutSec 60 + if ($record.id -ne 1214456 -or $record.metadata.doi -ne '10.5281/zenodo.1214456' -or + $record.metadata.license.id -ne 'cc-by-4.0') { + throw 'Zenodo identity or license did not match the frozen NCT-CRC acquisition policy.' + } + foreach ($name in $expected.Keys) { + $file = @($record.files | Where-Object key -eq $name) + if ($file.Count -ne 1 -or [long]$file[0].size -ne [long]$expected[$name].bytes -or + $file[0].checksum -ne "md5:$($expected[$name].md5)") { + throw "Zenodo file metadata changed: $name" + } + } + Write-JsonAtomic $recordPath $record + Get-BitsTransfer -Name $jobName -ErrorAction SilentlyContinue | + Remove-BitsTransfer -Confirm:$false -ErrorAction SilentlyContinue + $durableScript = Join-Path $acquisitionRoot 'acquire-nct-crc-he.ps1' + Copy-Item -LiteralPath $PSCommandPath -Destination $durableScript -Force + $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$durableScript`" -Action Run -StateRoot `"$state`"" + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 7) + $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` + -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew ` + -ExecutionTimeLimit (New-TimeSpan -Days 7) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Principal $principal ` + -Settings $settings -Description 'Run checksum-bound NCT-CRC acquisition for PathLab.' -Force | Out-Null + Write-Status 'transferring' 0 $requiredBytes 'Clean non-resumable acquisition worker scheduled.' + Get-Content -LiteralPath $statusPath -Raw + return +} + +if (Test-Path -LiteralPath $statusPath) { + $prior = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json + if ($prior.state -eq 'completed') { Remove-Finalizer; return } + if ($prior.state -eq 'failed') { Remove-Finalizer; return } +} + +$curl = (Get-Command curl.exe -ErrorAction Stop).Source +foreach ($name in $expected.Keys) { + $download = Join-Path $downloadRoot $name + if (Test-Path -LiteralPath $download -PathType Leaf) { + if ((Get-Item -LiteralPath $download).Length -eq [long]$expected[$name].bytes) { continue } + throw "Existing acquisition file has the wrong size: $name" + } + $partial = "$download.partial" + if (Test-Path -LiteralPath $partial -PathType Leaf) { + $partialLength = [long](Get-Item -LiteralPath $partial).Length + if ($partialLength -eq [long]$expected[$name].bytes -and + (Get-FileHash -LiteralPath $partial -Algorithm MD5).Hash.ToLowerInvariant() -eq + $expected[$name].md5) { + Move-Item -LiteralPath $partial -Destination $download + continue + } + Move-ToQuarantine $partial 'Interrupted, oversized, or checksum-invalid non-resumable transfer.' + } + $url = "https://zenodo.org/api/records/$recordId/files/$([Uri]::EscapeDataString($name))/content" + $exitCode = -1 + for ($attempt = 1; $attempt -le 3; $attempt++) { + if (Test-Path -LiteralPath $partial -PathType Leaf) { + Remove-Item -LiteralPath $partial -Force + } + Write-Status 'transferring' (Get-DownloadedBytes) $requiredBytes "Downloading $name (attempt $attempt of 3)" + $arguments = @('--fail','--location','--silent','--show-error','--remove-on-error', + '--output',('"' + $partial + '"'),$url) + $process = Start-Process -FilePath $curl -ArgumentList $arguments -PassThru -NoNewWindow + while (-not $process.HasExited) { + Write-Status 'transferring' (Get-DownloadedBytes) $requiredBytes "Downloading $name (attempt $attempt of 3)" + Start-Sleep -Seconds 15 + $process.Refresh() + } + $process.WaitForExit() + $exitCode = [int]$process.ExitCode + if ($exitCode -eq 0) { break } + if (Test-Path -LiteralPath $partial -PathType Leaf) { + Remove-Item -LiteralPath $partial -Force + } + if ($attempt -lt 3) { + $delay = if ($attempt -eq 1) { 5 } else { 30 } + Write-Status 'transient_error' (Get-DownloadedBytes) $requiredBytes ` + "Download attempt $attempt failed with curl exit $exitCode; retrying in $delay seconds." + Start-Sleep -Seconds $delay + } + } + if ($exitCode -ne 0) { + Write-Status 'failed' (Get-DownloadedBytes) $requiredBytes ` + "Download stopped after 3 attempts; curl exit $exitCode." + throw "NCT-CRC download failed after three attempts with curl exit code $exitCode." + } + if ((Get-Item -LiteralPath $partial).Length -ne [long]$expected[$name].bytes) { + Write-Status 'failed' (Get-DownloadedBytes) $requiredBytes "Downloaded size mismatch: $name" + throw "NCT-CRC downloaded size mismatch: $name" + } + Move-Item -LiteralPath $partial -Destination $download +} + +Write-Status 'validating' $requiredBytes $requiredBytes 'Validating official MD5 and local SHA-256 checksums.' +New-Item -ItemType Directory -Path $sourceRoot -Force | Out-Null +$ledger = [Collections.Generic.List[string]]::new() +foreach ($name in $expected.Keys) { + $download = Join-Path $downloadRoot $name + if ((Get-Item -LiteralPath $download).Length -ne [long]$expected[$name].bytes -or + (Get-FileHash -LiteralPath $download -Algorithm MD5).Hash.ToLowerInvariant() -ne $expected[$name].md5) { + Write-Status 'failed' 0 $requiredBytes "Checksum validation failed: $name" + throw "NCT-CRC checksum validation failed: $name" + } + $target = Join-Path $sourceRoot $name + Move-Item -LiteralPath $download -Destination $target -Force + $ledger.Add(([ordered]@{ + sampleId = [IO.Path]::GetFileNameWithoutExtension($name); source = 'Zenodo record 1214456' + patientGroup = 'dataset-defined-multiple-patients'; slideGroup = 'dataset-defined-patches' + sha256 = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + upstreamMd5 = $expected[$name].md5; bytes = [long]$expected[$name].bytes + license = 'CC-BY-4.0'; permittedUse = 'private-research'; task = 'he-retrieval-qualification' + split = if ($name -like 'CRC-VAL*') { 'query' } else { 'reference' } + } | ConvertTo-Json -Compress)) +} +[IO.File]::WriteAllLines($ledgerPath, $ledger, [Text.UTF8Encoding]::new($false)) +Copy-Item -LiteralPath $recordPath -Destination (Join-Path $sourceRoot 'zenodo-record.json') -Force +Remove-Item -LiteralPath $reservationPath -Force +Write-Status 'completed' $requiredBytes $requiredBytes 'NCT-CRC source archives are checksum-verified and ledgered.' +Remove-Finalizer diff --git a/scripts/acquire-tcga-lung-he.ps1 b/scripts/acquire-tcga-lung-he.ps1 new file mode 100644 index 0000000..e57d2c2 --- /dev/null +++ b/scripts/acquire-tcga-lung-he.ps1 @@ -0,0 +1,235 @@ +[CmdletBinding()] +param( + [ValidateSet('Start','Run','Status')] [string] $Action = 'Start', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$datasetId = 'tcga-luad-lusc-he-20x2-v1' +$taskName = 'PathLabTcgaLungAcquisition' +$sourceLimit = 45GB +$state = [IO.Path]::GetFullPath($StateRoot) +$acquisitionRoot = Join-Path $state "acquisition\$datasetId" +$downloadRoot = Join-Path $acquisitionRoot 'downloads' +$sourceRoot = Join-Path $state "sources\$datasetId" +$statusPath = Join-Path $acquisitionRoot 'status.json' +$manifestPath = Join-Path $acquisitionRoot 'gdc-files.json' +$ledgerPath = Join-Path $sourceRoot 'sample-ledger.jsonl' +$reservationPath = Join-Path $state "quota\reservations\source\$datasetId.reservation" + +function Write-JsonAtomic([string] $Path, [object] $Value) { + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path -Force +} +function Write-Status([string] $StateValue, [long] $Completed, [long] $Total, [string] $Detail) { + Write-JsonAtomic $statusPath ([ordered]@{ + schema='pathlab.acquisition-status/1'; datasetId=$datasetId; state=$StateValue + completedBytes=$Completed; totalBytes=$Total; detail=$Detail + networkContext='interactive-user-acquisition-only'; analysisNetwork='disabled' + updatedAt=[DateTimeOffset]::UtcNow.ToString('o') + }) +} +function Directory-Bytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $measure = Get-ChildItem -LiteralPath $Path -File -Recurse -ErrorAction Stop | + Measure-Object Length -Sum + if ($null -eq $measure.Sum) { return 0L } + return [long]$measure.Sum +} +function Downloaded-Bytes($Files) { + $total = 0L + foreach ($file in $Files) { + foreach ($candidate in @((Join-Path $downloadRoot $file.file_name), + (Join-Path $downloadRoot "$($file.file_name).partial"), + (Join-Path $sourceRoot $file.file_name))) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $total += [Math]::Min([long](Get-Item -LiteralPath $candidate).Length, [long]$file.bytes) + break + } + } + } + return $total +} +function Query-Project([string] $Project) { + $filter = @{op='and';content=@( + @{op='in';content=@{field='cases.project.project_id';value=@($Project)}}, + @{op='in';content=@{field='data_type';value=@('Slide Image')}}, + @{op='in';content=@{field='data_format';value=@('SVS')}}, + @{op='in';content=@{field='access';value=@('open')}} + )} | ConvertTo-Json -Depth 8 -Compress + $fields = 'file_id,file_name,file_size,md5sum,access,data_type,data_format,' + + 'cases.case_id,cases.submitter_id,cases.project.project_id,cases.samples.sample_type' + $response = Invoke-RestMethod -Method Post -Uri 'https://api.gdc.cancer.gov/files' -TimeoutSec 90 ` + -Body @{filters=$filter;format='JSON';fields=$fields;size='2000'} + return @($response.data.hits | ForEach-Object { + $case = $_.cases[0] + $sampleTypes = @($case.samples.sample_type) + $normal = @($sampleTypes | Where-Object { $_ -match 'Normal' }).Count -gt 0 + [pscustomobject]@{ + file_id=[string]$_.file_id; file_name=[string]$_.file_name + bytes=[long]$_.file_size; md5=[string]$_.md5sum + case_id=[string]$case.case_id; patient=[string]$case.submitter_id + project=[string]$case.project.project_id + phenotype=if($normal){'lung-normal'}else{'lung-tumor'} + sample_type=($sampleTypes -join ',') + } + }) +} + +if ($Action -eq 'Status') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { Get-Content -LiteralPath $statusPath -Raw } + else { '{"schema":"pathlab.acquisition-status/1","state":"not_started"}' } + return +} +foreach ($directory in @($acquisitionRoot,$downloadRoot,(Split-Path -Parent $reservationPath))) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } +} + +if ($Action -eq 'Start') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { + $prior = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json + if ($prior.state -eq 'completed') { $prior | ConvertTo-Json -Depth 8; return } + } + $selected = [Collections.Generic.List[object]]::new() + foreach ($project in @('TCGA-LUAD','TCGA-LUSC')) { + $split = if ($project -eq 'TCGA-LUAD') {'reference'} else {'query'} + $hits = Query-Project $project + $projectPatients = @{} + foreach ($phenotype in @('lung-normal','lung-tumor')) { + $candidates = @($hits | Where-Object phenotype -eq $phenotype | + Sort-Object bytes,file_id) + foreach ($candidate in $candidates) { + if ($projectPatients.ContainsKey($candidate.patient)) { continue } + $projectPatients[$candidate.patient] = $true + $candidate | Add-Member -NotePropertyName split -NotePropertyValue $split + $candidate | Add-Member -NotePropertyName source_group -NotePropertyValue $project + $selected.Add($candidate) + if (@($selected | Where-Object { $_.project -eq $project -and $_.phenotype -eq $phenotype }).Count -eq 10) { break } + } + if (@($selected | Where-Object { $_.project -eq $project -and $_.phenotype -eq $phenotype }).Count -ne 10) { + throw "$project lacks ten patient-distinct $phenotype open slides." + } + } + } + if ($selected.Count -ne 40 -or @($selected.patient | Sort-Object -Unique).Count -ne 40) { + throw 'The deterministic TCGA lung selection is not patient-disjoint.' + } + $requiredBytes = [long](($selected | Measure-Object bytes -Sum).Sum) + $used = Directory-Bytes (Join-Path $state 'sources') + $reserved = 0L + Get-ChildItem -LiteralPath (Split-Path -Parent $reservationPath) -Filter '*.reservation' -File ` + -ErrorAction SilentlyContinue | Where-Object FullName -ne $reservationPath | ForEach-Object { + $reserved += [long]([IO.File]::ReadAllText($_.FullName).Trim()) + } + if ($requiredBytes -gt $sourceLimit - $used - $reserved) { + throw 'The 45 GB new-source quota cannot reserve the bounded TCGA lung cohort.' + } + if (-not (Test-Path -LiteralPath $reservationPath -PathType Leaf)) { + [IO.File]::WriteAllText("$reservationPath.partial", [string]$requiredBytes, + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath + } + Write-JsonAtomic $manifestPath ([ordered]@{ + schema='pathlab.gdc-acquisition-manifest/1'; datasetId=$datasetId + api='https://api.gdc.cancer.gov/files'; dataEndpoint='https://api.gdc.cancer.gov/data/{file_id}' + access='open'; policy='NIH Genomic Data Sharing and NCI GDC open-access policies' + policyUrl='https://gdc.cancer.gov/about-gdc/gdc-policies' + permittedUse='private-research'; frozenAt=[DateTimeOffset]::UtcNow.ToString('o') + selection='10 normal and 10 tumor patient-distinct smallest open SVS files per project' + files=$selected.ToArray(); totalBytes=$requiredBytes + }) + $durableScript = Join-Path $acquisitionRoot 'acquire-tcga-lung-he.ps1' + Copy-Item -LiteralPath $PSCommandPath -Destination $durableScript -Force + $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$durableScript`" -Action Run -StateRoot `"$state`"" + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 7) + $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` + -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew ` + -ExecutionTimeLimit (New-TimeSpan -Days 7) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Principal $principal ` + -Settings $settings -Description 'Acquire checksum-bound open TCGA lung slides for PathLab.' -Force | Out-Null + Write-Status 'transferring' 0 $requiredBytes 'Bounded open-access GDC acquisition scheduled.' + Start-ScheduledTask -TaskName $taskName + Get-Content -LiteralPath $statusPath -Raw + return +} + +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ($manifest.schema -ne 'pathlab.gdc-acquisition-manifest/1' -or + $manifest.permittedUse -ne 'private-research' -or @($manifest.files).Count -ne 40) { + throw 'The frozen GDC acquisition manifest is invalid.' +} +$files = @($manifest.files) +$curl = (Get-Command curl.exe -ErrorAction Stop).Source +foreach ($file in $files) { + $target = Join-Path $downloadRoot ([string]$file.file_name) + if (Test-Path -LiteralPath $target -PathType Leaf) { + if ((Get-Item -LiteralPath $target).Length -eq [long]$file.bytes -and + (Get-FileHash -LiteralPath $target -Algorithm MD5).Hash.ToLowerInvariant() -eq $file.md5) { continue } + throw "Existing TCGA acquisition file is invalid: $($file.file_name)" + } + $partial = "$target.partial" + if (Test-Path -LiteralPath $partial -PathType Leaf) { Remove-Item -LiteralPath $partial -Force } + $exitCode = -1 + for ($attempt=1; $attempt -le 3; $attempt++) { + Write-Status 'transferring' (Downloaded-Bytes $files) ([long]$manifest.totalBytes) ` + "Downloading $($file.file_name) (attempt $attempt of 3)" + $url = "https://api.gdc.cancer.gov/data/$($file.file_id)" + $process = Start-Process -FilePath $curl -ArgumentList @('--fail','--location','--silent', + '--show-error','--remove-on-error','--output',('"'+$partial+'"'),$url) -PassThru -NoNewWindow + while (-not $process.HasExited) { + Write-Status 'transferring' (Downloaded-Bytes $files) ([long]$manifest.totalBytes) ` + "Downloading $($file.file_name) (attempt $attempt of 3)" + Start-Sleep -Seconds 10 + $process.Refresh() + } + $process.WaitForExit(); $exitCode=[int]$process.ExitCode + if ($exitCode -eq 0) { break } + Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue + if ($attempt -lt 3) { Start-Sleep -Seconds $(if($attempt -eq 1){5}else{30}) } + } + if ($exitCode -ne 0 -or -not (Test-Path -LiteralPath $partial -PathType Leaf) -or + (Get-Item -LiteralPath $partial).Length -ne [long]$file.bytes -or + (Get-FileHash -LiteralPath $partial -Algorithm MD5).Hash.ToLowerInvariant() -ne $file.md5) { + Write-Status 'failed' (Downloaded-Bytes $files) ([long]$manifest.totalBytes) ` + "Download or checksum failed: $($file.file_name)" + throw "TCGA lung acquisition failed closed: $($file.file_name)" + } + Move-Item -LiteralPath $partial -Destination $target +} + +Write-Status 'validating' ([long]$manifest.totalBytes) ([long]$manifest.totalBytes) ` + 'Validating GDC MD5 values and recording local SHA-256 checksums.' +New-Item -ItemType Directory -Path $sourceRoot -Force | Out-Null +$ledger = [Collections.Generic.List[string]]::new() +foreach ($file in $files) { + $download = Join-Path $downloadRoot ([string]$file.file_name) + if ((Get-FileHash -LiteralPath $download -Algorithm MD5).Hash.ToLowerInvariant() -ne $file.md5) { + throw "Final GDC checksum changed: $($file.file_name)" + } + $target = Join-Path $sourceRoot ([string]$file.file_name) + Move-Item -LiteralPath $download -Destination $target -Force + $ledger.Add(([ordered]@{ + sampleId=[string]$file.file_id; source="NCI GDC/$($file.project)/$($file.file_name)" + patientGroup=[string]$file.patient; slideGroup=[string]$file.file_id + sha256=(Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + upstreamMd5=[string]$file.md5; bytes=[long]$file.bytes + license='NIH-GDS/NCI-GDC-open-access-policy'; permittedUse='private-research' + task='he-retrieval-qualification'; taskLabel=[string]$file.phenotype + split=[string]$file.split; sourceGroup=[string]$file.source_group + } | ConvertTo-Json -Compress)) +} +[IO.File]::WriteAllLines($ledgerPath,$ledger,[Text.UTF8Encoding]::new($false)) +Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $sourceRoot 'gdc-files.json') -Force +Remove-Item -LiteralPath $reservationPath -Force +Write-Status 'completed' ([long]$manifest.totalBytes) ([long]$manifest.totalBytes) ` + 'Forty patient-distinct open TCGA lung slides are checksum-verified and ledgered.' +Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue diff --git a/scripts/acquire-tumorquantai-ihc-subset.ps1 b/scripts/acquire-tumorquantai-ihc-subset.ps1 new file mode 100644 index 0000000..b5f834c --- /dev/null +++ b/scripts/acquire-tumorquantai-ihc-subset.ps1 @@ -0,0 +1,166 @@ +[CmdletBinding()] +param( + [ValidateSet('Start','Run','Status')] [string] $Action = 'Start', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$recordId = '21797920' +$datasetId = 'tumorquantai-breast-ihc-4case-v1' +$taskName = 'PathLabTumorQuantIhcAcquisition' +$sourceLimit = 45GB +$expected = @( + [ordered]@{case='TQA_BC_ZPVYVY4T27UKEAYPKAOX';name='TQA_BC_ZPVYVY4T27UKEAYPKAOX.zip';bytes=80105716L;md5='6d63faf7ba43d598bece904c7deafbcd';sha256='772c193f2e71d6afed5630d5f74a3fd73ac892070587aacea8a39106baa9be14'}, + [ordered]@{case='TQA_BC_KHLKIB6TVKGYE7SUWAOX';name='TQA_BC_KHLKIB6TVKGYE7SUWAOX.zip';bytes=85446094L;md5='003df2c43571fe3dfc9b853ff061376f';sha256='c92ad0567931f7079d5271da58db998fa22e954a4157614262a84fc44108b997'}, + [ordered]@{case='TQA_BC_2R5PE76UT27ESW6WXFR7';name='TQA_BC_2R5PE76UT27ESW6WXFR7.zip';bytes=85446098L;md5='4ab4ad3d13b003f8527560497288a292';sha256='a56bd9a978b48ea5906e773f72d795ed37b326da1177776bfd9ee37e8b4fb12e'}, + [ordered]@{case='TQA_BC_5QIEJCI66QT6FMUHJ67O';name='TQA_BC_5QIEJCI66QT6FMUHJ67O.zip';bytes=170892174L;md5='66ffcff753b501f98f46b885c8a6d8bc';sha256='35105f2b5d13edd4cae94e26fa309eb1a9e094402e8df8a8d68aa5a6232d4412'} +) +$requiredBytes = 0L +foreach ($item in $expected) { $requiredBytes += [long]$item.bytes } +$state = [IO.Path]::GetFullPath($StateRoot) +$preparationRoot = Join-Path $state 'acquisition\tumorquantai-breast-ihc-manifest-v1' +$root = Join-Path $state "acquisition\$datasetId" +$downloadRoot = Join-Path $root 'downloads' +$sourceRoot = Join-Path $state "sources\$datasetId" +$statusPath = Join-Path $root 'status.json' +$recordPath = Join-Path $root 'zenodo-record.json' +$ledgerPath = Join-Path $sourceRoot 'sample-ledger.jsonl' +$reservationPath = Join-Path $state "quota\reservations\source\$datasetId.reservation" + +function Write-JsonAtomic([string] $Path, [object] $Value) { + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} +function Write-Status([string] $StateValue, [long] $Completed, [string] $Detail) { + Write-JsonAtomic $statusPath ([ordered]@{ + schema='pathlab.acquisition-status/1';datasetId=$datasetId;state=$StateValue + completedBytes=$Completed;totalBytes=$requiredBytes;detail=$Detail + networkContext='interactive-user-acquisition-only';analysisNetwork='disabled' + updatedAt=[DateTimeOffset]::UtcNow.ToString('o') + }) +} +function Directory-Bytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = Get-ChildItem -LiteralPath $Path -File -Recurse | Measure-Object Length -Sum + if ($null -eq $sum -or $null -eq $sum.Sum) { return 0L }; return [long]$sum.Sum +} +function Get-DownloadedBytes { + $total=0L + foreach($item in $expected) { + $found = $false + foreach($path in @((Join-Path $downloadRoot $item.name),(Join-Path $downloadRoot "$($item.name).partial"))) { + if(Test-Path -LiteralPath $path -PathType Leaf) { + $total += [Math]::Min([long](Get-Item $path).Length,[long]$item.bytes) + $found = $true + break + } + } + if (-not $found) { + $chunkRoot = Join-Path $downloadRoot ".chunks\$($item.name)" + $total += [Math]::Min((Directory-Bytes $chunkRoot), [long]$item.bytes) + } + } + return $total +} +function Finish-Task { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } + +if ($Action -eq 'Status') { + if(Test-Path -LiteralPath $statusPath -PathType Leaf){Get-Content $statusPath -Raw}else{'{"schema":"pathlab.acquisition-status/1","state":"not_started"}'} + return +} +New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null +$reservationRoot = Split-Path -Parent $reservationPath +if (-not (Test-Path -LiteralPath $reservationRoot -PathType Container)) { + throw 'The service-managed source quota reservation root is unavailable.' +} + +if ($Action -eq 'Start') { + $review = Get-Content (Join-Path $preparationRoot 'rights-and-integrity.json') -Raw | ConvertFrom-Json + if($review.datasetLicense -ne 'CC-BY-4.0' -or $review.qualificationStatus -ne 'not_evaluable') { + throw 'Pinned TumorQuantAI rights review is unavailable or unexpectedly promotes qualification.' + } + $manifest = Import-Csv (Join-Path $preparationRoot 'manifest\archive_manifest.csv') + foreach($item in $expected) { + $row=@($manifest|Where-Object archive_filename -eq $item.name) + if($row.Count -ne 1 -or [long]$row[0].archive_size_bytes -ne $item.bytes -or + $row[0].md5 -ne $item.md5 -or $row[0].sha256 -ne $item.sha256) { + throw "Frozen TumorQuantAI archive manifest changed: $($item.name)" + } + } + $used=Directory-Bytes (Join-Path $state 'sources');$reserved=0L + Get-ChildItem (Split-Path -Parent $reservationPath) -Filter '*.reservation' -File -ErrorAction SilentlyContinue | + Where-Object FullName -ne $reservationPath | ForEach-Object {$reserved += [long]([IO.File]::ReadAllText($_.FullName).Trim())} + if($requiredBytes -gt $sourceLimit-$used-$reserved){throw 'The 45 GB source quota cannot reserve the TumorQuantAI subset.'} + if(-not(Test-Path $reservationPath)){[IO.File]::WriteAllText("$reservationPath.partial",[string]$requiredBytes);Move-Item "$reservationPath.partial" $reservationPath} + $record=Invoke-RestMethod -Uri "https://zenodo.org/api/records/$recordId" -TimeoutSec 60 + if([string]$record.id -ne $recordId -or $record.metadata.doi -ne '10.5281/zenodo.21797920' -or $record.metadata.license.id -ne 'cc-by-4.0'){throw 'TumorQuantAI record identity or rights changed.'} + foreach($item in $expected){$file=@($record.files|Where-Object key -eq $item.name);if($file.Count-ne 1-or[long]$file[0].size-ne$item.bytes-or$file[0].checksum-ne"md5:$($item.md5)"){throw "Zenodo metadata changed: $($item.name)"}} + Write-JsonAtomic $recordPath $record + $durable=Join-Path $root 'acquire-tumorquantai-ihc-subset.ps1';Copy-Item $PSCommandPath $durable -Force + $taskAction=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$durable`" -Action Run -StateRoot `"$state`"" + $trigger=New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) + $principal=New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited + $settings=New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Days 7) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Principal $principal -Settings $settings -Description 'Acquire bounded TumorQuantAI descriptive IHC fixtures.' -Force|Out-Null + Write-Status 'queued' 0 'Checksum-bound four-case download scheduled.';Start-ScheduledTask $taskName;Get-Content $statusPath -Raw;return +} + +$curl=(Get-Command curl.exe -ErrorAction Stop).Source +$chunkBytes = 8MB +try { + foreach($item in $expected){ + $target=Join-Path $downloadRoot $item.name + if(Test-Path $target){if((Get-Item $target).Length-eq$item.bytes-and(Get-FileHash $target -Algorithm SHA256).Hash.ToLowerInvariant()-eq$item.sha256){continue};throw "Existing file mismatch: $($item.name)"} + $partial="$target.partial";Remove-Item $partial -Force -ErrorAction SilentlyContinue + $chunkRoot=Join-Path $downloadRoot ".chunks\$($item.name)" + New-Item -ItemType Directory -Path $chunkRoot -Force|Out-Null + $url="https://zenodo.org/api/records/$recordId/files/$([Uri]::EscapeDataString($item.name))/content" + $chunkIndex=0 + for($offset=0L;$offset-lt[long]$item.bytes;$offset+=$chunkBytes){ + $end=[Math]::Min([long]$item.bytes-1,$offset+$chunkBytes-1) + $expectedChunkBytes=$end-$offset+1 + $chunk=Join-Path $chunkRoot ('{0:D5}.part' -f $chunkIndex) + if(Test-Path $chunk){if((Get-Item $chunk).Length-eq$expectedChunkBytes){$chunkIndex++;continue};Remove-Item $chunk -Force} + if((Test-Path "$chunk.partial")-and(Get-Item "$chunk.partial").Length-eq$expectedChunkBytes){Move-Item -LiteralPath "$chunk.partial" -Destination $chunk -Force;$chunkIndex++;continue} + $exitCode=-1 + for ($attempt = 1; $attempt -le 3; $attempt++) { + Write-Status 'transferring' (Get-DownloadedBytes) "Downloading $($item.name) chunk $($chunkIndex+1) (attempt $attempt of 3)." + $process=Start-Process $curl -ArgumentList @('--fail','--location','--silent','--show-error','--remove-on-error','--range',"$offset-$end",'--output',('"'+$chunk+'.partial"'),$url) -PassThru -NoNewWindow + while(-not $process.HasExited){Write-Status 'transferring' (Get-DownloadedBytes) "Downloading $($item.name) chunk $($chunkIndex+1) (attempt $attempt of 3).";Start-Sleep 15;$process.Refresh()} + $process.WaitForExit();$exitCode=$process.ExitCode + if((Test-Path "$chunk.partial")-and(Get-Item "$chunk.partial").Length-eq$expectedChunkBytes){Move-Item -LiteralPath "$chunk.partial" -Destination $chunk -Force;break} + Remove-Item "$chunk.partial" -Force -ErrorAction SilentlyContinue + if($attempt-lt 3){Start-Sleep -Seconds $(if($attempt-eq 1){5}else{30})} + } + if(-not(Test-Path $chunk)-or(Get-Item $chunk).Length-ne$expectedChunkBytes){throw "TumorQuantAI chunk transfer failed: $($item.name) bytes $offset-$end"} + $chunkIndex++ + } + $output=[IO.File]::Open($partial,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::None) + try {Get-ChildItem $chunkRoot -Filter '*.part' -File|Sort-Object Name|ForEach-Object{$input=[IO.File]::OpenRead($_.FullName);try{$input.CopyTo($output)}finally{$input.Dispose()}}} finally {$output.Dispose()} + if((Get-Item $partial).Length-ne$item.bytes-or(Get-FileHash $partial -Algorithm SHA256).Hash.ToLowerInvariant()-ne$item.sha256){throw "TumorQuantAI assembled checksum mismatch: $($item.name)"} + Move-Item $partial $target + Remove-Item $chunkRoot -Recurse -Force + } +} catch { + Write-Status 'failed' (Get-DownloadedBytes) $_.Exception.Message + Finish-Task + throw +} + +Write-Status 'validating' $requiredBytes 'Publishing checksum and case-disjoint source ledger.' +New-Item -ItemType Directory -Path $sourceRoot -Force|Out-Null +$lines=[Collections.Generic.List[string]]::new() +foreach($item in $expected){$from=Join-Path $downloadRoot $item.name;$to=Join-Path $sourceRoot $item.name;Move-Item $from $to -Force;$lines.Add(([ordered]@{ + sampleId=$item.case;source='Zenodo record 21797920';patientGroup = $item.case;slideGroup=$item.case + sha256=$item.sha256;upstreamSha256=$item.sha256;upstreamMd5=$item.md5;bytes=$item.bytes + license='CC-BY-4.0';permittedUse='private-research-descriptive-only';task='ihc-descriptive-integration';split='case-disjoint-fixture' +}|ConvertTo-Json -Compress))} +[IO.File]::WriteAllLines($ledgerPath,$lines,[Text.UTF8Encoding]::new($false)) +Copy-Item (Join-Path $preparationRoot 'manifest\patch_manifest.csv') (Join-Path $sourceRoot 'patch_manifest.csv') -Force +Copy-Item $recordPath (Join-Path $sourceRoot 'zenodo-record.json') -Force +Remove-Item $reservationPath -Force +Write-Status 'completed' $requiredBytes 'Four case-disjoint archives are checksum-verified; scientific IHC qualification remains not_evaluable.' +Finish-Task diff --git a/scripts/build-bracs-breast-dinov2-cohort.ps1 b/scripts/build-bracs-breast-dinov2-cohort.ps1 new file mode 100644 index 0000000..20f04f0 --- /dev/null +++ b/scripts/build-bracs-breast-dinov2-cohort.ps1 @@ -0,0 +1,190 @@ +[CmdletBinding()] +param( + [string] $DatasetRoot = 'D:\PathLabData\BRACS', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $FrozenAt = '2026-08-24T06:30:00Z', + [long] $DerivedUsedBytes = -1 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$cohortId = 'bracs-roi-breast-retrieval-20x2-v1' +$derivedQuotaBytes = 25GB +$estimatedBytes = 100MB +$state = [IO.Path]::GetFullPath($StateRoot) +$derivedRoot = [IO.Path]::GetFullPath((Join-Path $state 'derived')) +$outputRoot = [IO.Path]::GetFullPath((Join-Path $derivedRoot "qualification-prepared\$cohortId")) +$partialRoot = "$outputRoot.partial" +$reservationPath = "$outputRoot.reservation" +$manifestPath = Join-Path $DatasetRoot 'prepared\bracs-roi-clean-v1\manifest.csv' +$provenancePath = Join-Path $DatasetRoot 'prepared\bracs-roi-clean-v1\provenance.json' +$rawRoot = [IO.Path]::GetFullPath((Join-Path $DatasetRoot 'raw\BRACS_RoI\latest_version')) + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path +} +function Get-TreeBytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = 0L + Get-ChildItem -LiteralPath $Path -File -Recurse | ForEach-Object { $sum += [long]$_.Length } + return $sum +} +function Select-BoundedRows([object[]] $Rows, [string] $Split, [hashtable] $Quotas) { + $selected = [Collections.Generic.List[object]]::new() + foreach ($label in @($Quotas.Keys | Sort-Object)) { + $eligible = @($Rows | Where-Object { $_.split -eq $Split -and $_.label -eq $label } | + Sort-Object patient_id,slide_id,roi_id) + $chosen = [Collections.Generic.List[object]]::new() + $patients = @{} + foreach ($row in $eligible) { + if ($patients.ContainsKey([string]$row.patient_id)) { continue } + $patients[[string]$row.patient_id] = $true; $chosen.Add($row) + if ($chosen.Count -eq [int]$Quotas[$label]) { break } + } + if ($chosen.Count -lt [int]$Quotas[$label]) { + $chosenIds = @{}; $chosen | ForEach-Object { $chosenIds[[string]$_.roi_id] = $true } + foreach ($row in $eligible) { + if ($chosenIds.ContainsKey([string]$row.roi_id)) { continue } + $chosen.Add($row) + if ($chosen.Count -eq [int]$Quotas[$label]) { break } + } + } + if ($chosen.Count -ne [int]$Quotas[$label]) { throw "BRACS $Split lacks bounded samples for $label." } + $chosen | ForEach-Object { $selected.Add($_) } + } + return $selected.ToArray() +} +function Get-WindowScore([Drawing.Bitmap] $Image, [int] $X, [int] $Y) { + $tissue = 0; $contrast = 0.0; $count = 0 + for ($py=$Y; $py -lt $Y+512; $py+=16) { + for ($px=$X; $px -lt $X+512; $px+=16) { + $pixel=$Image.GetPixel($px,$py); $count++ + $maximum=[Math]::Max($pixel.R,[Math]::Max($pixel.G,$pixel.B)) + $minimum=[Math]::Min($pixel.R,[Math]::Min($pixel.G,$pixel.B)) + $luminance=0.2126*$pixel.R+0.7152*$pixel.G+0.0722*$pixel.B + if ($luminance -ge 30 -and $luminance -le 240 -and ($maximum-$minimum) -ge 8) { + $tissue++; $contrast += $maximum-$minimum + } + } + } + return 100.0*$tissue/[Math]::Max(1,$count)+$contrast/[Math]::Max(1,$tissue) +} + +try { [void][DateTimeOffset]::Parse($FrozenAt) } catch { throw 'FrozenAt must be an immutable timestamp.' } +if (-not $outputRoot.StartsWith($derivedRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { throw 'BRACS cohort path escaped the derived-data root.' } +foreach ($required in @($manifestPath,$provenancePath,$rawRoot)) { + if (-not (Test-Path -LiteralPath $required)) { throw "Reviewed BRACS input is unavailable: $required" } +} +$provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json +if ($provenance.dataset -ne 'BRACS_RoI_latest_version' -or + $provenance.license_declared_by_current_source -ne 'CC-BY-NC-4.0' -or + -not $provenance.patient_disjoint -or (Sha256 $manifestPath) -ne $provenance.manifest_sha256) { + throw 'BRACS provenance, rights, split isolation, or manifest checksum changed.' +} +if (Test-Path -LiteralPath $outputRoot -PathType Container) { + $existing = Join-Path $outputRoot 'cohort.json' + if (-not (Test-Path -LiteralPath $existing -PathType Leaf)) { throw 'Existing BRACS cohort is incomplete.' } + Write-Output "Existing immutable cohort: $existing"; Write-Output "SHA256: $(Sha256 $existing)"; return +} +if ((Test-Path -LiteralPath $partialRoot) -or (Test-Path -LiteralPath $reservationPath)) { + throw 'A partial BRACS breast cohort requires review.' +} +$used = if ($DerivedUsedBytes -ge 0) { $DerivedUsedBytes } else { Get-TreeBytes $derivedRoot } +if ($estimatedBytes -gt $derivedQuotaBytes-$used) { throw 'The 25 GB derived-data quota cannot reserve BRACS.' } +[IO.File]::WriteAllText("$reservationPath.partial", [string]$estimatedBytes, [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath + +$rows = @(Import-Csv -LiteralPath $manifestPath | Where-Object { + $_.mode -eq 'RGB' -and [int]$_.width -ge 512 -and [int]$_.height -ge 512 +}) +$breastQuotas = @{ADH=5;DCIS=5;FEA=5;IC=5} +$benignQuotas = @{N=7;PB=7;UDH=6} +$reference = @((Select-BoundedRows $rows 'val' $breastQuotas) + (Select-BoundedRows $rows 'val' $benignQuotas)) +$query = @((Select-BoundedRows $rows 'test' $breastQuotas) + (Select-BoundedRows $rows 'test' $benignQuotas)) +if ($reference.Count -ne 40 -or $query.Count -ne 40 -or + @($reference.patient_id | Where-Object { $_ -in $query.patient_id }).Count -ne 0) { + throw 'BRACS reference/query selection is not patient-disjoint 40 by 40.' +} + +Add-Type -AssemblyName System.Drawing +$samples = [Collections.Generic.List[object]]::new() +try { + New-Item -ItemType Directory -Path (Join-Path $partialRoot 'samples') -Force | Out-Null + foreach ($entry in @(@($reference | ForEach-Object { [pscustomobject]@{row=$_;split='reference'} }) + + @($query | ForEach-Object { [pscustomobject]@{row=$_;split='query'} }))) { + $row=$entry.row; $split=$entry.split + $source=[IO.Path]::GetFullPath((Join-Path $rawRoot ([string]$row.relative_path))) + if (-not $source.StartsWith($rawRoot+[IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $source -PathType Leaf) -or + (Get-Item -LiteralPath $source).Length -ne [long]$row.file_size_bytes -or + (Sha256 $source) -ne [string]$row.sha256) { throw "BRACS source changed: $($row.roi_id)" } + $sampleId=([string]$row.roi_id).ToLowerInvariant().Replace('_','-') + $sampleRoot=Join-Path $partialRoot "samples\$sampleId" + New-Item -ItemType Directory -Path $sampleRoot -Force | Out-Null + $image=[Drawing.Bitmap]::new($source) + try { + if ($image.Width -ne [int]$row.width -or $image.Height -ne [int]$row.height) { + throw "BRACS decoded geometry changed: $($row.roi_id)" + } + $xs=@(0,[int](($image.Width-512)/2),($image.Width-512))|Sort-Object -Unique + $ys=@(0,[int](($image.Height-512)/2),($image.Height-512))|Sort-Object -Unique + $candidates=foreach($y in $ys){foreach($x in $xs){[pscustomobject]@{X=$x;Y=$y;Score=Get-WindowScore $image $x $y}}} + $chosen=@($candidates|Sort-Object @{Expression='Score';Descending=$true},Y,X)[0] + $tile=$image.Clone([Drawing.Rectangle]::new($chosen.X,$chosen.Y,512,512), + [Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $tile.Save((Join-Path $sampleRoot 'source.png'),[Drawing.Imaging.ImageFormat]::Png) } finally { $tile.Dispose() } + } finally { $image.Dispose() } + $tilePath=Join-Path $sampleRoot 'source.png'; $tileSha=Sha256 $tilePath + $sampleManifest=Join-Path $sampleRoot 'sample.json' + Write-JsonAtomic $sampleManifest ([ordered]@{ + schema='pathlab.evidence-sample/1';source="BRACS_RoI_latest_version#$($row.sha256)" + patientGroup=[string]$row.patient_id;slideId=[string]$row.slide_id;sha256=$tileSha + license='CC-BY-NC-4.0';taskLabel="breast-$(([string]$row.label).ToLowerInvariant())" + permittedUse='private-research';bytes=[long](Get-Item $tilePath).Length;grandfatheredReadOnly=$true + }) + $tileManifest=Join-Path $sampleRoot 'tile-cache.json' + Write-JsonAtomic $tileManifest ([ordered]@{ + schema='pathlab.tile-cache/1';source=[ordered]@{ + sha256=$tileSha;slideRevision="bracs-roi:$($row.roi_id):$($row.sha256)" + width=[int]$row.width;height=[int]$row.height;sampleManifest='sample.json' + sampleManifestSha256=Sha256 $sampleManifest + };tilePixels=512;encoding='png';preprocessingInput='rgb-srgb-uint8' + tiles=@([ordered]@{id='tile-001';path='source.png';sha256=$tileSha;x=$chosen.X;y=$chosen.Y;width=512;height=512}) + }) + $evaluationGroup=if($row.label -in @('N','PB','UDH')){'benign-reactive'}else{'breast'} + $samples.Add([ordered]@{ + id=$sampleId;tileCacheManifest="samples/$sampleId/tile-cache.json" + tileCacheManifestSha256=Sha256 $tileManifest;evaluationGroup=$evaluationGroup + phenotypeGroup=[string]$row.label;split=$split;sourceGroup='bracs-roi-single-source' + }) + } + if (@($samples | Where-Object split -eq 'reference').Count -ne 40 -or + @($samples | Where-Object split -eq 'query').Count -ne 40) { throw 'BRACS cohort sample counts changed.' } + Write-JsonAtomic (Join-Path $partialRoot 'cohort.json') ([ordered]@{ + schema='pathlab.qualification-cohort/1';cohortId=$cohortId;frozenAt=$FrozenAt + intendedUse='private-research-model-qualification';qualificationStatus='not_evaluable' + qualificationReasons=@('SOURCE_HELD_OUT_NOT_MET_SINGLE_BRACS_RELEASE','LYMPH_NODE_GI_LUNG_AND_OOD_GROUPS_ABSENT') + acceptanceCriteria=[ordered]@{ + requiredEvaluationGroups=@('breast','gi','lung','lymph-node','benign-reactive') + minimumReferenceSamplesPerGroup=20;minimumQuerySamplesPerGroup=20;minimumOodSamples=20 + maximumPatientOverlap=0;maximumSlideOverlap=0;maximumSourceOverlap=0 + baselineId='color-histogram-v1';minimumMacroRecallAt5Improvement=0.05 + minimumMacroNdcgAt10Improvement=0.03;minimumOodAuRoc=0.80 + };samples=$samples.ToArray() + }) + if ((Get-TreeBytes $partialRoot) -gt $estimatedBytes) { throw 'BRACS cohort exceeded its reservation.' } + New-Item -ItemType Directory -Path (Split-Path -Parent $outputRoot) -Force | Out-Null + Move-Item -LiteralPath $partialRoot -Destination $outputRoot + Remove-Item -LiteralPath $reservationPath -Force +} catch { throw } +$cohortPath=Join-Path $outputRoot 'cohort.json' +Write-Output "Built immutable BRACS breast retrieval cohort: $cohortPath" +Write-Output 'Samples: 80 (40 reference, 40 query)' +Write-Output "SHA256: $(Sha256 $cohortPath)" +Write-Output 'Qualification remains NOT_EVALUABLE because BRACS is one source and GI, lung, lymph-node, and OOD groups are absent.' diff --git a/scripts/build-bracs-dinov2-tile-cache.ps1 b/scripts/build-bracs-dinov2-tile-cache.ps1 new file mode 100644 index 0000000..2b96800 --- /dev/null +++ b/scripts/build-bracs-dinov2-tile-cache.ps1 @@ -0,0 +1,243 @@ +[CmdletBinding()] +param( + [string]$DatasetRoot = 'D:\PathLabData\BRACS', + [string]$StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string]$FrozenAt = '2026-08-22T00:00:00Z', + [ValidateRange(1, 5)][int]$SamplesPerLabel = 1, + [ValidateRange(1, 5)][int]$TilesPerSample = 4 +) + +$ErrorActionPreference = 'Stop' +$derivedQuotaBytes = 25GB +$cohortId = 'bracs-roi-smoke-v1' +$manifestPath = Join-Path $DatasetRoot 'prepared\bracs-roi-clean-v1\manifest.jsonl' +$manifestCsvPath = Join-Path $DatasetRoot 'prepared\bracs-roi-clean-v1\manifest.csv' +$provenancePath = Join-Path $DatasetRoot 'prepared\bracs-roi-clean-v1\provenance.json' +$rawRoot = Join-Path $DatasetRoot 'raw\BRACS_RoI\latest_version' +$derivedRoot = [IO.Path]::GetFullPath((Join-Path $StateRoot 'derived')) +$outputRoot = [IO.Path]::GetFullPath((Join-Path $derivedRoot "he-dinov2-small-v1\$cohortId")) +$partialRoot = "$outputRoot.partial" + +try { + [void][DateTimeOffset]::ParseExact($FrozenAt, 'yyyy-MM-ddTHH:mm:ssZ', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal) +} catch { + throw 'FrozenAt must be an immutable UTC timestamp such as 2026-08-22T00:00:00Z.' +} + +if (-not $outputRoot.StartsWith($derivedRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'BRACS tile-cache path escaped the Evidence Mentor derived-data root.' +} +if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf) -or + -not (Test-Path -LiteralPath $manifestCsvPath -PathType Leaf) -or + -not (Test-Path -LiteralPath $provenancePath -PathType Leaf) -or + -not (Test-Path -LiteralPath $rawRoot -PathType Container)) { + throw 'The reviewed local BRACS ROI dataset is unavailable.' +} +if (Test-Path -LiteralPath $outputRoot) { + $cohort = Join-Path $outputRoot 'cohort.json' + if (-not (Test-Path -LiteralPath $cohort -PathType Leaf)) { + throw 'The existing BRACS tile cache is incomplete and requires review.' + } + Write-Output "Existing immutable cohort: $cohort" + Write-Output "SHA256: $((Get-FileHash -LiteralPath $cohort -Algorithm SHA256).Hash.ToLowerInvariant())" + return +} +if (Test-Path -LiteralPath $partialRoot) { + throw 'A partial BRACS tile-cache build requires review before retrying.' +} + +function Get-TreeBytes([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = 0L + Get-ChildItem -LiteralPath $Path -Recurse -File | ForEach-Object { + $sum = [long]($sum + $_.Length) + } + return $sum +} + +$provenance = Get-Content -Raw -LiteralPath $provenancePath | ConvertFrom-Json +if ($provenance.dataset -ne 'BRACS_RoI_latest_version' -or + $provenance.license_declared_by_current_source -ne 'CC-BY-NC-4.0' -or + -not $provenance.patient_disjoint) { + throw 'BRACS provenance does not match the reviewed patient-disjoint research dataset.' +} +$actualManifestSha = (Get-FileHash -LiteralPath $manifestCsvPath -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actualManifestSha -ne $provenance.manifest_sha256) { + throw 'BRACS source manifest checksum does not match its provenance record.' +} + +$rows = @(Get-Content -LiteralPath $manifestPath | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.split -eq 'val' -and $_.mode -eq 'RGB' -and + $_.width -ge 512 -and $_.height -ge 512 } | + Sort-Object label, patient_id, slide_id, roi_id) +$selected = New-Object System.Collections.Generic.List[object] +foreach ($label in @('N', 'PB', 'UDH', 'FEA', 'ADH', 'DCIS', 'IC')) { + $seenPatients = @{} + foreach ($row in @($rows | Where-Object label -eq $label)) { + if ($seenPatients.ContainsKey([string]$row.patient_id)) { continue } + $seenPatients[[string]$row.patient_id] = $true + $selected.Add($row) + if (($selected | Where-Object label -eq $label).Count -ge $SamplesPerLabel) { break } + } + if (($selected | Where-Object label -eq $label).Count -lt $SamplesPerLabel) { + throw "BRACS validation split lacks enough distinct patients for label $label." + } +} + +$estimatedBytes = [long]$selected.Count * $TilesPerSample * 1024 * 1024 +$usedBytes = Get-TreeBytes $derivedRoot +if ($usedBytes -gt $derivedQuotaBytes -or $estimatedBytes -gt ($derivedQuotaBytes - $usedBytes)) { + throw 'Evidence Mentor derived-data quota is exhausted.' +} +$reservationRoot = Join-Path $StateRoot 'quota\reservations\derived' +New-Item -ItemType Directory -Path $reservationRoot -Force | Out-Null +$reservation = Join-Path $reservationRoot "build-$cohortId.reservation" +$reservationPartial = "$reservation.partial" +if ((Test-Path -LiteralPath $reservation) -or (Test-Path -LiteralPath $reservationPartial)) { + throw 'A BRACS tile-cache quota reservation already exists.' +} +[IO.File]::WriteAllText($reservationPartial, [string]$estimatedBytes) +Move-Item -LiteralPath $reservationPartial -Destination $reservation + +Add-Type -AssemblyName System.Drawing +try { + New-Item -ItemType Directory -Path $partialRoot -Force | Out-Null + $cohortSamples = New-Object System.Collections.Generic.List[object] + foreach ($row in $selected) { + $source = [IO.Path]::GetFullPath((Join-Path $rawRoot ([string]$row.relative_path))) + if (-not $source.StartsWith([IO.Path]::GetFullPath($rawRoot) + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "BRACS source escaped its reviewed root or is unavailable: $($row.roi_id)" + } + $sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash.ToLowerInvariant() + if ($sourceHash -ne $row.sha256 -or (Get-Item -LiteralPath $source).Length -ne $row.file_size_bytes) { + throw "BRACS source checksum failed: $($row.roi_id)" + } + $sampleRoot = Join-Path $partialRoot "samples\$($row.roi_id)" + $tileRoot = Join-Path $sampleRoot 'tiles' + New-Item -ItemType Directory -Path $tileRoot -Force | Out-Null + $sampleManifest = [ordered]@{ + schema = 'pathlab.evidence-sample/1' + source = 'BRACS_RoI_latest_version' + patientGroup = [string]$row.patient_id + slideId = [string]$row.slide_id + sha256 = $sourceHash + license = 'CC-BY-NC-4.0 declared by reviewed BRACS source' + taskLabel = "breast-$(([string]$row.label).ToLowerInvariant())" + permittedUse = 'private-research' + bytes = [long]$row.file_size_bytes + grandfatheredReadOnly = $true + } + $samplePath = Join-Path $sampleRoot 'sample.json' + [IO.File]::WriteAllText("$samplePath.partial", (($sampleManifest | ConvertTo-Json -Depth 6) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$samplePath.partial" -Destination $samplePath + + $image = New-Object Drawing.Bitmap $source + try { + if ($image.Width -ne $row.width -or $image.Height -ne $row.height) { + throw "BRACS decoded geometry does not match its manifest: $($row.roi_id)" + } + $positions = @( + [pscustomobject]@{ X = [int](($image.Width - 512) / 2); Y = [int](($image.Height - 512) / 2) }, + [pscustomobject]@{ X = 0; Y = 0 }, + [pscustomobject]@{ X = $image.Width - 512; Y = 0 }, + [pscustomobject]@{ X = 0; Y = $image.Height - 512 }, + [pscustomobject]@{ X = $image.Width - 512; Y = $image.Height - 512 } + ) | Sort-Object X, Y -Unique | Select-Object -First $TilesPerSample + $tiles = New-Object System.Collections.Generic.List[object] + $tileIndex = 0 + foreach ($position in $positions) { + $tileIndex++ + $tileName = 'tile-{0:D3}.png' -f $tileIndex + $tilePath = Join-Path $tileRoot $tileName + $rectangle = New-Object Drawing.Rectangle $position.X, $position.Y, 512, 512 + $tile = $image.Clone($rectangle, [Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { + $partial = "$tilePath.partial" + $tile.Save($partial, [Drawing.Imaging.ImageFormat]::Png) + Move-Item -LiteralPath $partial -Destination $tilePath + } finally { + $tile.Dispose() + } + $tiles.Add([ordered]@{ + id = 'tile-{0:D3}' -f $tileIndex + path = "tiles/$tileName" + sha256 = (Get-FileHash -LiteralPath $tilePath -Algorithm SHA256).Hash.ToLowerInvariant() + x = [int]$position.X + y = [int]$position.Y + width = 512 + height = 512 + }) + } + } finally { + $image.Dispose() + } + $tileManifest = [ordered]@{ + schema = 'pathlab.tile-cache/1' + source = [ordered]@{ + sha256 = $sourceHash + slideRevision = "bracs-roi:$($row.roi_id):$sourceHash" + width = [int]$row.width + height = [int]$row.height + sampleManifest = 'sample.json' + sampleManifestSha256 = (Get-FileHash -LiteralPath $samplePath -Algorithm SHA256).Hash.ToLowerInvariant() + } + tilePixels = 512 + encoding = 'png' + preprocessingInput = 'rgb-srgb-uint8' + tiles = $tiles.ToArray() + } + $tileManifestPath = Join-Path $sampleRoot 'tile-cache.json' + [IO.File]::WriteAllText("$tileManifestPath.partial", (($tileManifest | ConvertTo-Json -Depth 10) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$tileManifestPath.partial" -Destination $tileManifestPath + $evaluationGroup = if ($row.label -in @('N', 'PB', 'UDH')) { 'benign-reactive' } else { 'breast' } + $cohortSamples.Add([ordered]@{ + id = ([string]$row.roi_id).ToLowerInvariant().Replace('_', '-') + tileCacheManifest = "samples/$($row.roi_id)/tile-cache.json" + tileCacheManifestSha256 = (Get-FileHash -LiteralPath $tileManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + evaluationGroup = $evaluationGroup + phenotypeGroup = [string]$row.label + split = 'query' + sourceGroup = 'bracs-roi-current-source' + }) + } + $cohort = [ordered]@{ + schema = 'pathlab.qualification-cohort/1' + cohortId = $cohortId + frozenAt = $FrozenAt + intendedUse = 'private-research-model-qualification' + acceptanceCriteria = [ordered]@{ + requiredEvaluationGroups = @('breast', 'gi', 'lung', 'lymph-node', 'benign-reactive') + minimumReferenceSamplesPerGroup = 20 + minimumQuerySamplesPerGroup = 20 + minimumOodSamples = 20 + maximumPatientOverlap = 0 + maximumSlideOverlap = 0 + maximumSourceOverlap = 0 + baselineId = 'color-histogram-v1' + minimumMacroRecallAt5Improvement = 0.05 + minimumMacroNdcgAt10Improvement = 0.03 + minimumOodAuRoc = 0.80 + } + samples = $cohortSamples.ToArray() + } + $cohortPath = Join-Path $partialRoot 'cohort.json' + [IO.File]::WriteAllText("$cohortPath.partial", (($cohort | ConvertTo-Json -Depth 12) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$cohortPath.partial" -Destination $cohortPath + New-Item -ItemType Directory -Path (Split-Path $outputRoot) -Force | Out-Null + Move-Item -LiteralPath $partialRoot -Destination $outputRoot + Remove-Item -LiteralPath $reservation -Force +} catch { + throw +} + +$finalCohort = Join-Path $outputRoot 'cohort.json' +Write-Output "Built immutable BRACS breast-only smoke cohort: $finalCohort" +Write-Output "SHA256: $((Get-FileHash -LiteralPath $finalCohort -Algorithm SHA256).Hash.ToLowerInvariant())" +Write-Output 'Qualification remains NOT_EVALUABLE: reference, GI, lung, lymph-node and OOD coverage are absent.' diff --git a/scripts/build-hovernet-fast-monusac-pack.ps1 b/scripts/build-hovernet-fast-monusac-pack.ps1 new file mode 100644 index 0000000..6868efa --- /dev/null +++ b/scripts/build-hovernet-fast-monusac-pack.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param([string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state') + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$packId = 'cell-hovernet-fast-monusac-v1' +$version = '7' +$modelLimit = 10GB +$reservationBytes = 512MB +$candidateId = 'hovernet-fast-monusac-v1' +$candidateVersion = '1' +$codeCommit = '67e2ce5e3f1a64a2ece77ad1c24233653a9e0901' +$codeSha256 = 'a14857014569fc169371f04832fe96e605f755d35151f59901e1ee7176a05914' +$weightName = 'hovernet_fast_monusac_type_tf2pytorch.tar' +$weightSha256 = '5b1c642d9884e20c8fa0b80a6cfef793f483d47eaa5df6183baddc3f57e88a35' +$runtimeManifestSha256 = 'b52c4d80f914c7e6e56d05e594d9478333d763861b5133e3c82af2a6a17fd942' +$scipyName = 'scipy-1.18.1-cp312-cp312-win_amd64.whl' +$scipyBytes = 36658278L +$scipySha256 = '5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2' +$scipyUrl = 'https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl' +$state = [IO.Path]::GetFullPath($StateRoot) +$modelsRoot = [IO.Path]::GetFullPath((Join-Path $state 'models')) +$candidateRoot = Join-Path $modelsRoot "$candidateId\$candidateVersion" +$runtimeRoot = Join-Path $modelsRoot 'he-dinov2-small-v1\1' +$installRoot = Join-Path $modelsRoot "$packId\$version" +$stagingRoot = Join-Path $modelsRoot ".partial-$packId-$version" +$reservationPath = Join-Path $modelsRoot ".acquisition-reservations\$packId.reservation" +$workerSource = Join-Path (Split-Path -Parent $PSScriptRoot) 'src\main\resources\model-workers\hovernet-worker.py' + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Directory-Bytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = 0L + Get-ChildItem -LiteralPath $Path -File -Recurse | ForEach-Object { $sum = [long]($sum + $_.Length) } + return $sum +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 30) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} +function File-Ledger([string] $Root) { + $rootPrefix = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + @(Get-ChildItem -LiteralPath $Root -File -Recurse | Sort-Object FullName | ForEach-Object { + if (-not $_.FullName.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Model build ledger path escaped its root.' + } + [ordered]@{path=$_.FullName.Substring($rootPrefix.Length).Replace('\','/');bytes=$_.Length;sha256=(Sha256 $_.FullName)} + }) +} + +if (Test-Path -LiteralPath (Join-Path $installRoot 'build-receipt.json') -PathType Leaf) { + $receipt = Get-Content -LiteralPath (Join-Path $installRoot 'build-receipt.json') -Raw | ConvertFrom-Json + if ($receipt.schema -ne 'pathlab.model-pack-build/1' -or $receipt.packId -ne $packId -or + (Sha256 (Join-Path $installRoot 'worker.py')) -ne [string]$receipt.workerSha256 -or + (Sha256 (Join-Path $installRoot 'runtime-reference.json')) -ne [string]$receipt.runtimeReferenceSha256) { + throw 'Existing HoVer-Net executable pack failed its immutable receipt.' + } + Write-Output "Already built $packId/$version under $installRoot" + return +} +if (Test-Path -LiteralPath $installRoot) { throw 'Incomplete HoVer-Net install root requires review.' } + +$candidateLedgerPath = Join-Path $candidateRoot 'candidate-ledger.json' +$codeArchive = Join-Path $candidateRoot "hover_net-$codeCommit.zip" +$weightPath = Join-Path $candidateRoot $weightName +$runtimeManifest = Join-Path $runtimeRoot 'runtime-manifest.json' +foreach ($required in @($candidateLedgerPath,$codeArchive,$weightPath,$runtimeManifest,$workerSource)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required HoVer-Net build input is unavailable: $required" } +} +if ((Sha256 $codeArchive) -ne $codeSha256 -or (Sha256 $weightPath) -ne $weightSha256 -or + (Sha256 $runtimeManifest) -ne $runtimeManifestSha256) { + throw 'A frozen HoVer-Net or shared-runtime input checksum changed.' +} +$candidateLedger = Get-Content -LiteralPath $candidateLedgerPath -Raw | ConvertFrom-Json +if ($candidateLedger.candidateId -ne $candidateId -or $candidateLedger.atlasCleanEligible -ne $false -or + $candidateLedger.qualificationStatus -ne 'not_evaluable') { + throw 'The acquired HoVer-Net rights or qualification boundary changed.' +} + +New-Item -ItemType Directory -Path (Split-Path -Parent $reservationPath) -Force | Out-Null +$used = Directory-Bytes $modelsRoot +$reserved = 0L +Get-ChildItem -LiteralPath (Split-Path -Parent $reservationPath) -Filter '*.reservation' -File -ErrorAction SilentlyContinue | + Where-Object FullName -ne $reservationPath | ForEach-Object { $reserved += [long]([IO.File]::ReadAllText($_.FullName).Trim()) } +if ($reservationBytes -gt $modelLimit - $used - $reserved) { throw 'The 10 GB model quota cannot reserve the HoVer-Net adapter.' } +[IO.File]::WriteAllText("$reservationPath.partial", [string]$reservationBytes) +Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath -Force + +try { + New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null + $dependencies = Join-Path $stagingRoot 'dependencies' + $overlay = Join-Path $stagingRoot 'overlay' + $upstream = Join-Path $stagingRoot 'upstream' + New-Item -ItemType Directory -Path $dependencies,$overlay,$upstream -Force | Out-Null + $scipyWheel = Join-Path $dependencies $scipyName + if (Test-Path -LiteralPath $scipyWheel -PathType Leaf) { + if ((Get-Item -LiteralPath $scipyWheel).Length -ne $scipyBytes -or (Sha256 $scipyWheel) -ne $scipySha256) { + throw 'Existing pinned SciPy wheel checksum changed.' + } + } else { + Invoke-WebRequest -Uri $scipyUrl -OutFile "$scipyWheel.partial" -Headers @{'User-Agent'='PathLab-Forge/2.1 research acquisition'} -MaximumRedirection 10 + if ((Get-Item -LiteralPath "$scipyWheel.partial").Length -ne $scipyBytes -or + (Sha256 "$scipyWheel.partial") -ne $scipySha256) { throw 'Pinned SciPy wheel checksum changed.' } + Move-Item -LiteralPath "$scipyWheel.partial" -Destination $scipyWheel + } + + & tar.exe -xf $scipyWheel -C $overlay + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $overlay 'scipy\__init__.py') -PathType Leaf)) { + throw 'Pinned SciPy overlay extraction failed.' + } + $prefix = "hover_net-$codeCommit" + $sourceEntries = @( + "$prefix/models/__init__.py", "$prefix/models/hovernet/__init__.py", + "$prefix/models/hovernet/net_desc.py", "$prefix/models/hovernet/net_utils.py", + "$prefix/models/hovernet/utils.py" + ) + & tar.exe -xf $codeArchive -C $upstream --strip-components 1 @sourceEntries + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $upstream 'models\hovernet\net_desc.py') -PathType Leaf)) { + throw 'Pinned HoVer-Net source extraction failed.' + } + $utilsPath = Join-Path $upstream 'models\hovernet\utils.py' + $utilsText = [IO.File]::ReadAllText($utilsPath).Replace("from matplotlib import cm`n",'').Replace("from matplotlib import cm`r`n",'') + [IO.File]::WriteAllText($utilsPath, $utilsText, [Text.UTF8Encoding]::new($false)) + $netUtilsPath = Join-Path $upstream 'models\hovernet\net_utils.py' + $netUtilsText = [IO.File]::ReadAllText($netUtilsPath).Replace("from config import Config`n",'').Replace("from config import Config`r`n",'') + [IO.File]::WriteAllText($netUtilsPath, $netUtilsText, [Text.UTF8Encoding]::new($false)) + Copy-Item -LiteralPath $workerSource -Destination (Join-Path $stagingRoot 'worker.py') + + Write-JsonAtomic (Join-Path $stagingRoot 'source-manifest.json') ([ordered]@{ + schema='pathlab.model-source-manifest/1';repository='https://github.com/vqdang/hover_net.git' + commit=$codeCommit;license='MIT';files=(File-Ledger $upstream) + }) + Write-JsonAtomic (Join-Path $stagingRoot 'overlay-manifest.json') ([ordered]@{ + schema='pathlab.model-overlay-manifest/1';package='scipy';version='1.18.1' + wheel=$scipyName;wheelSha256=$scipySha256;license='BSD-3-Clause';files=(File-Ledger $overlay) + }) + Write-JsonAtomic (Join-Path $stagingRoot 'runtime-reference.json') ([ordered]@{ + schema='pathlab.model-runtime-reference/1';sharedRuntimePack='he-dinov2-small-v1';sharedRuntimeVersion='1' + sharedRuntimeRoot=$runtimeRoot;sharedRuntimeManifestSha256=$runtimeManifestSha256 + pythonRelativePath='runtime/Scripts/python.exe';candidateId=$candidateId;candidateVersion=$candidateVersion + candidateRoot=$candidateRoot;candidateLedgerSha256=(Sha256 $candidateLedgerPath) + weightFile=$weightName;weightSha256=$weightSha256;runtimeCopiedIntoCandidate=$false;analysisNetwork='disabled' + }) + $workerSha = Sha256 (Join-Path $stagingRoot 'worker.py') + $sourceManifestSha = Sha256 (Join-Path $stagingRoot 'source-manifest.json') + $overlayManifestSha = Sha256 (Join-Path $stagingRoot 'overlay-manifest.json') + $runtimeReferenceSha = Sha256 (Join-Path $stagingRoot 'runtime-reference.json') + Write-JsonAtomic (Join-Path $stagingRoot 'pack.json') ([ordered]@{ + schema='pathlab.ai-pack/1';packId=$packId;version=$version;capability='cell-morphology';acceptedStains=@('he','ihc_dab') + preprocessing=[ordered]@{id='hovernet-fast-monusac-v1';tilePixels=512} + artifacts=@( + [ordered]@{name='workerSource';sha256=$workerSha;source='repo:src/main/resources/model-workers/hovernet-worker.py'}, + [ordered]@{name='source-manifest';sha256=$sourceManifestSha;source="github:vqdang/hover_net@$codeCommit"}, + [ordered]@{name='overlay-manifest';sha256=$overlayManifestSha;source="pypi:$scipyName"}, + [ordered]@{name='runtime-reference';sha256=$runtimeReferenceSha;source='pathlab-local:he-dinov2-small-v1/1'}, + [ordered]@{name='weights';sha256=$weightSha256;source='official-hovernet:monusac-fast-checkpoint'} + ) + runtimeCompatibility=[ordered]@{workerProtocol='pathlab.model-worker/2';executionProvider='cuda';cuda='12.6';gpuArchitecture='sm_61';requiresExternalWorker=$true} + licenseLedger=@( + [ordered]@{component='HoVer-Net code';license='MIT';revision=$codeCommit;permittedUse='private-research';redistributable=$false;derivativesAllowed=$true}, + [ordered]@{component='MoNuSAC-derived weights';license='CC-BY-NC-SA-4.0';revision=$weightSha256;permittedUse='private-research';redistributable=$false;derivativesAllowed=$false}, + [ordered]@{component='SciPy compatibility overlay';license='BSD-3-Clause';revision='1.18.1';permittedUse='private-research';redistributable=$false;derivativesAllowed=$true} + ) + rights=[ordered]@{license='Mixed restricted research lineage';allowedUse='benchmark-only';redistributable=$false;derivativesAllowed=$false;reviewedAt=[DateTimeOffset]::UtcNow.ToString('o')} + resourceEnvelope=[ordered]@{maxRamMiB=16384;maxVramMiB=4608;maxSeconds=1200;network=$false} + validation=[ordered]@{status='not-evaluable';modelCard='docs/model-cards/cell-hovernet-fast-monusac-v1.md';heldOutEvaluation='not-evaluable-held-out-campaign-pending'} + outputSchema='pathlab.ai-evidence/1' + }) + Write-JsonAtomic (Join-Path $stagingRoot 'build-receipt.json') ([ordered]@{ + schema='pathlab.model-pack-build/1';packId=$packId;version=$version;workerSha256=$workerSha + runtimeReferenceSha256=$runtimeReferenceSha;runtimeCopiedIntoCandidate=$false + analysisNetwork='disabled';qualificationStatus='not_evaluable';activationEligible=$false + builtAt=[DateTimeOffset]::UtcNow.ToString('o') + }) + $actual = Directory-Bytes $stagingRoot + $other = (Directory-Bytes $modelsRoot) - $actual + if ($actual -gt $reservationBytes -or $actual -gt $modelLimit - $other) { throw 'HoVer-Net adapter exceeded its model quota reservation.' } + New-Item -ItemType Directory -Path (Split-Path -Parent $installRoot) -Force | Out-Null + Move-Item -LiteralPath $stagingRoot -Destination $installRoot + Remove-Item -LiteralPath $reservationPath -Force + Write-Output "Built $packId/$version under $installRoot ($actual bytes; shared runtime not copied)." +} catch { + throw +} diff --git a/scripts/build-monusac-cell-cohort.ps1 b/scripts/build-monusac-cell-cohort.ps1 new file mode 100644 index 0000000..7966ba6 --- /dev/null +++ b/scripts/build-monusac-cell-cohort.ps1 @@ -0,0 +1,190 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CohortId = 'monusac2020-cell-heldout-23-v1', + [long] $DerivedUsedBytes = -1 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$derivedQuotaBytes = 25GB +$estimatedBytes = 512MB +$state = [IO.Path]::GetFullPath($StateRoot) +$sourceRoot = Join-Path $state 'sources\monusac2020-official-v1' +$derivedRoot = Join-Path $state 'derived' +$outputRoot = Join-Path $derivedRoot "qualification-prepared\$CohortId" +$partialRoot = "$outputRoot.partial" +$reservationPath = "$outputRoot.reservation" +$trainingArchive = Join-Path $sourceRoot 'MoNuSAC_images_and_annotations.zip' +$testingArchive = Join-Path $sourceRoot 'MoNuSAC Testing Data and Annotations.zip' +$supplement = Join-Path $sourceRoot 'MoNuSAC_Supplementary_material.pdf' +$expectedTrainingSha = '5b7cbeb34817a8f880d3fddc28391e48d3329a91bf3adcbd131ea149a725cd92' +$expectedTestingSha = 'bcbc38f6bf8b149230c90c29f3428cc7b2b76f8acd7766ce9fc908fc896c2674' +$expectedSupplementSha = '2e4e7774559595a77ed57387ff47177a1da65ccff6154bc2c5d311c8ef02a878' +$excludedTrainingOverlap = @('TCGA-MP-A4T7','TCGA-A2-A0ES') +$organPatients = [ordered]@{ + lung=@('TCGA-49-6743','TCGA-50-6591','TCGA-55-7570','TCGA-55-7573','TCGA-73-4662','TCGA-78-7152') + kidney=@('TCGA-2Z-A9JG','TCGA-2Z-A9JN','TCGA-DW-7838','TCGA-DW-7963','TCGA-F9-A8NY','TCGA-IZ-A6M9','TCGA-MH-A55W') + breast=@('TCGA-A2-A04X','TCGA-D8-A3Z6','TCGA-E2-A108','TCGA-EW-A6SB') + prostate=@('TCGA-G9-6356','TCGA-G9-6367','TCGA-VP-A87E','TCGA-VP-A87H','TCGA-X4-A8KS','TCGA-YL-A9WL') +} + +function Get-Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 30) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} +function Get-TreeBytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = Get-ChildItem -LiteralPath $Path -File -Recurse | Measure-Object Length -Sum + if ($null -eq $sum -or $null -eq $sum.Sum) { return 0L } + return [long]$sum.Sum +} +function Find-VipsBinary([string] $Name) { + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + $candidate = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Filter $Name -File -Recurse -ErrorAction SilentlyContinue | + Where-Object FullName -Match 'libvips' | Select-Object -First 1 + if ($candidate) { return $candidate.FullName } + throw "$Name is required to materialize the MoNuSAC cohort." +} +function Invoke-Vips([string] $Executable, [string[]] $Arguments) { + & $Executable @Arguments + if ($LASTEXITCODE -ne 0) { throw "libvips failed with exit code $LASTEXITCODE." } +} +function Copy-ZipEntry([IO.Compression.ZipArchiveEntry] $Entry, [string] $Destination) { + $input = $Entry.Open() + $output = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $input.CopyTo($output) } finally { $output.Dispose(); $input.Dispose() } + if ((Get-Item -LiteralPath $Destination).Length -ne $Entry.Length) { + throw "ZIP entry extraction size mismatch: $($Entry.FullName)" + } +} +function Patient-FromEntry([string] $FullName) { + $segments = $FullName -split '/' + if ($segments.Count -lt 3 -or $segments[1].Length -lt 12) { return $null } + return $segments[1].Substring(0,12) +} + +if (-not $outputRoot.StartsWith($derivedRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { throw 'MoNuSAC cohort path escaped the derived-data root.' } +foreach ($required in @($trainingArchive,$testingArchive,$supplement)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required MoNuSAC source is unavailable: $required" } +} +if ((Get-Sha256 $trainingArchive) -ne $expectedTrainingSha -or + (Get-Sha256 $testingArchive) -ne $expectedTestingSha -or + (Get-Sha256 $supplement) -ne $expectedSupplementSha) { + throw 'A frozen MoNuSAC source checksum changed.' +} +if (Test-Path -LiteralPath $outputRoot -PathType Container) { + $existing = Join-Path $outputRoot 'cohort.json' + if (-not (Test-Path -LiteralPath $existing -PathType Leaf)) { throw 'Existing MoNuSAC cohort is incomplete.' } + Write-Output "Existing immutable cohort: $existing" + Write-Output "SHA256: $(Get-Sha256 $existing)" + return +} +if (Test-Path -LiteralPath $partialRoot) { throw 'A partial MoNuSAC cohort requires review before retrying.' } + +$used = if ($DerivedUsedBytes -ge 0) { $DerivedUsedBytes } else { Get-TreeBytes $derivedRoot } +if ($estimatedBytes -gt $derivedQuotaBytes - $used) { throw 'The 25 GB derived-data quota cannot reserve the MoNuSAC cohort.' } +New-Item -ItemType Directory -Path (Split-Path -Parent $reservationPath) -Force | Out-Null +[IO.File]::WriteAllText("$reservationPath.partial", [string]$estimatedBytes, [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath -Force + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$trainingStream = [IO.File]::OpenRead($trainingArchive) +$testingStream = [IO.File]::OpenRead($testingArchive) +$trainingZip = [IO.Compression.ZipArchive]::new($trainingStream, [IO.Compression.ZipArchiveMode]::Read, $false) +$testingZip = [IO.Compression.ZipArchive]::new($testingStream, [IO.Compression.ZipArchiveMode]::Read, $false) +$samples = [Collections.Generic.List[object]]::new() +try { + $trainingPatients = @($trainingZip.Entries | ForEach-Object { Patient-FromEntry $_.FullName } | + Where-Object { $_ } | Sort-Object -Unique) + $testingPatients = @($testingZip.Entries | ForEach-Object { Patient-FromEntry $_.FullName } | + Where-Object { $_ } | Sort-Object -Unique) + foreach ($overlap in $excludedTrainingOverlap) { + if ($overlap -notin $trainingPatients -or $overlap -notin $testingPatients) { + throw "Frozen overlap audit changed: $overlap" + } + } + $selectedPatients = @($organPatients.Values | ForEach-Object { $_ }) + if (@($selectedPatients | Sort-Object -Unique).Count -ne 23 -or + @($selectedPatients | Where-Object { $_ -in $trainingPatients }).Count -ne 0) { + throw 'The selected MoNuSAC patients are not held out from training.' + } + + $vips = Find-VipsBinary 'vips.exe' + $vipsHeader = Find-VipsBinary 'vipsheader.exe' + New-Item -ItemType Directory -Path (Join-Path $partialRoot 'samples') -Force | Out-Null + foreach ($organ in $organPatients.Keys) { + foreach ($patient in $organPatients[$organ]) { + $patientEntries = @($testingZip.Entries | Where-Object { (Patient-FromEntry $_.FullName) -eq $patient }) + $sourceEntry = @($patientEntries | Where-Object Name -Match '\.tif$' | Sort-Object FullName | Select-Object -First 1) + if ($sourceEntry.Count -eq 0) { + $sourceEntry = @($patientEntries | Where-Object Name -Match '\.svs$' | Sort-Object FullName | Select-Object -First 1) + } + if ($sourceEntry.Count -ne 1) { throw "No bounded image entry is available for $patient" } + $stem = [IO.Path]::GetFileNameWithoutExtension($sourceEntry[0].Name) + $xmlEntry = @($patientEntries | Where-Object { $_.Name -eq "$stem.xml" }) + if ($xmlEntry.Count -ne 1) { throw "The paired annotation is unavailable for $patient" } + + $sampleId = $stem.ToLowerInvariant() + $sampleRoot = Join-Path $partialRoot "samples\$sampleId" + New-Item -ItemType Directory -Path $sampleRoot -Force | Out-Null + $rawPath = Join-Path $sampleRoot ("source" + [IO.Path]::GetExtension($sourceEntry[0].Name).ToLowerInvariant()) + $annotationPath = Join-Path $sampleRoot 'annotation.xml' + Copy-ZipEntry $sourceEntry[0] $rawPath + Copy-ZipEntry $xmlEntry[0] $annotationPath + $imagePath = Join-Path $sampleRoot 'image.png' + Invoke-Vips $vips @('copy',$rawPath,"$imagePath.partial.png") + Move-Item -LiteralPath "$imagePath.partial.png" -Destination $imagePath + Remove-Item -LiteralPath $rawPath -Force + $width = [int]((& $vipsHeader -f width $imagePath).Trim()) + $height = [int]((& $vipsHeader -f height $imagePath).Trim()) + if ($LASTEXITCODE -ne 0 -or $width -lt 64 -or $height -lt 64 -or + [long]$width * $height -gt 4194304L) { throw "Unsupported MoNuSAC geometry for $patient" } + $relativeRoot = "samples/$sampleId" + $samples.Add([ordered]@{ + sampleId=$sampleId;patientGroup=$patient;slideGroup=$sourceEntry[0].FullName.Split('/')[1] + organ=$organ;split='qualification-held-out-test';patientOverlapWithTraining=$false + imagePath="$relativeRoot/image.png";imageSha256=Get-Sha256 $imagePath + annotationPath="$relativeRoot/annotation.xml";annotationSha256=Get-Sha256 $annotationPath + width=$width;height=$height;license='CC-BY-NC-SA-4.0' + permittedUse='private-research-restricted';sourceEntry=$sourceEntry[0].FullName + }) + } + } + if (@($samples).Count -ne 23 -or @($samples.organ | Sort-Object -Unique).Count -ne 4) { + throw 'The MoNuSAC held-out cohort is incomplete.' + } + Write-JsonAtomic (Join-Path $partialRoot 'cohort.json') ([ordered]@{ + schema='pathlab.cell-qualification-cohort/1';cohortId=$CohortId + createdAt='2026-08-24T00:00:00Z';sampleCount=23;organs=@('lung','kidney','breast','prostate') + source=[ordered]@{trainingArchiveSha256=$expectedTrainingSha;testingArchiveSha256=$expectedTestingSha + supplementarySha256=$expectedSupplementSha;upstreamChecksumAvailable=$false + integrity='official-exact-size-local-sha256-and-complete-selected-entry-read'} + rights=[ordered]@{license='CC-BY-NC-SA-4.0';permittedUse='private-research-restricted';atlasCleanEligible=$false} + splitPolicy=[ordered]@{patientHeldOutFromTraining=$true;excludedOverlapPatients=$excludedTrainingOverlap + selection='lexicographically-first-annotated-field-per-held-out-patient'} + gates=[ordered]@{minimumMacroPq=0.45;minimumInstanceDice=0.70;maximumCountError=0.15 + maximumMorphometryBias=0.10;maximumFailedRegionRate=0.05;requiresDeterministicRepeat=$true + requiresCrossTissuePerformance=$true} + samples=$samples + }) + $actualBytes = Get-TreeBytes $partialRoot + if ($actualBytes -gt $estimatedBytes) { throw 'The MoNuSAC cohort exceeded its derived-data reservation.' } + Move-Item -LiteralPath $partialRoot -Destination $outputRoot + Remove-Item -LiteralPath $reservationPath -Force + $cohortPath = Join-Path $outputRoot 'cohort.json' + Write-Output "Cohort: $cohortPath" + Write-Output "SHA256: $(Get-Sha256 $cohortPath)" + Write-Output "Samples: 23 patient-held-out fields across four organs" +} catch { + throw +} finally { + $testingZip.Dispose(); $testingStream.Dispose(); $trainingZip.Dispose(); $trainingStream.Dispose() +} diff --git a/scripts/build-nct-crc-dinov2-cohort.ps1 b/scripts/build-nct-crc-dinov2-cohort.ps1 new file mode 100644 index 0000000..aedaf42 --- /dev/null +++ b/scripts/build-nct-crc-dinov2-cohort.ps1 @@ -0,0 +1,243 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [ValidateRange(20, 100)] + [int] $SamplesPerClass = 20, + [string] $FrozenAt = '2026-08-23T03:02:46Z' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$classes = @('ADI', 'BACK', 'DEB', 'LYM', 'MUC', 'MUS', 'NORM', 'STR', 'TUM') +$state = [IO.Path]::GetFullPath($StateRoot) +$sourceRoot = [IO.Path]::GetFullPath((Join-Path $state 'sources\nct-crc-he-1214456')) +$derivedRoot = [IO.Path]::GetFullPath((Join-Path $state 'derived')) +$cohortId = "nct-crc-gi-$($SamplesPerClass)-per-class-v1" +$outputRoot = [IO.Path]::GetFullPath((Join-Path $derivedRoot "he-dinov2-small-v1\$cohortId")) +$partialRoot = "$outputRoot.partial" +$reservationRoot = Join-Path $state 'quota\reservations\derived' +$reservationPath = Join-Path $reservationRoot "build-$cohortId.reservation" +$derivedQuotaBytes = 25GB +$estimatedBytes = [long](2 * $classes.Count * $SamplesPerClass) * 1MB + +try { + [void][DateTimeOffset]::ParseExact($FrozenAt, 'yyyy-MM-ddTHH:mm:ssZ', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal) +} catch { + throw 'FrozenAt must be an immutable UTC timestamp such as 2026-08-23T03:02:46Z.' +} +if (-not $outputRoot.StartsWith($derivedRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'NCT-CRC cohort path escaped the derived-data root.' +} + +function Get-Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Get-StreamSha256([IO.Stream] $Stream) { + $algorithm = [Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($algorithm.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant() } + finally { $algorithm.Dispose() } +} +function Write-JsonAtomic([string] $Path, [object] $Value, [int] $Depth = 16) { + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, (($Value | ConvertTo-Json -Depth $Depth) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path +} +function Get-TreeBytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $total = 0L + Get-ChildItem -LiteralPath $Path -Recurse -File | ForEach-Object { $total += [long]$_.Length } + return $total +} + +$sourceLedgerPath = Join-Path $sourceRoot 'sample-ledger.jsonl' +if (-not (Test-Path -LiteralPath $sourceLedgerPath -PathType Leaf)) { + throw 'The checksum-verified NCT-CRC source ledger is unavailable.' +} +$sourceLedger = @(Get-Content -LiteralPath $sourceLedgerPath | ForEach-Object { $_ | ConvertFrom-Json }) +$archives = @( + [pscustomobject]@{ Name='NCT-CRC-HE-100K.zip'; Split='reference'; Prefix='NCT-CRC-HE-100K/' }, + [pscustomobject]@{ Name='CRC-VAL-HE-7K.zip'; Split='query'; Prefix='CRC-VAL-HE-7K/' } +) +foreach ($archive in $archives) { + $archive | Add-Member -NotePropertyName Path -NotePropertyValue (Join-Path $sourceRoot $archive.Name) + $ledger = @($sourceLedger | Where-Object sampleId -eq ([IO.Path]::GetFileNameWithoutExtension($archive.Name))) + if ($ledger.Count -ne 1 -or -not (Test-Path -LiteralPath $archive.Path -PathType Leaf) -or + (Get-Item -LiteralPath $archive.Path).Length -ne [long]$ledger[0].bytes -or + (Get-Sha256 $archive.Path) -ne [string]$ledger[0].sha256 -or + $ledger[0].license -ne 'CC-BY-4.0' -or $ledger[0].permittedUse -ne 'private-research') { + throw "NCT-CRC source rights, size, or checksum changed: $($archive.Name)" + } + $archive | Add-Member -NotePropertyName Sha256 -NotePropertyValue ([string]$ledger[0].sha256) +} +if (Test-Path -LiteralPath $outputRoot) { + $cohortPath = Join-Path $outputRoot 'cohort.json' + if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { + throw 'The existing NCT-CRC cohort is incomplete and requires review.' + } + Write-Output "Existing immutable cohort: $cohortPath" + Write-Output "SHA256: $(Get-Sha256 $cohortPath)" + return +} +if (Test-Path -LiteralPath $partialRoot) { + throw 'A partial NCT-CRC cohort requires review before retrying.' +} + +$usedBytes = Get-TreeBytes $derivedRoot +$reservedBytes = 0L +if (Test-Path -LiteralPath $reservationRoot -PathType Container) { + Get-ChildItem -LiteralPath $reservationRoot -File -ErrorAction SilentlyContinue | ForEach-Object { + $reservedBytes += [long]([IO.File]::ReadAllText($_.FullName).Trim()) + } +} +if ($usedBytes -gt $derivedQuotaBytes -or $estimatedBytes -gt ($derivedQuotaBytes - $usedBytes - $reservedBytes)) { + throw 'The 25 GB derived-data quota cannot reserve the NCT-CRC cohort.' +} +New-Item -ItemType Directory -Path $reservationRoot -Force | Out-Null +[IO.File]::WriteAllText("$reservationPath.partial", [string]$estimatedBytes, + [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath + +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.IO.Compression.FileSystem +$samples = [Collections.Generic.List[object]]::new() +$ledgerLines = [Collections.Generic.List[string]]::new() +try { + New-Item -ItemType Directory -Path (Join-Path $partialRoot 'samples') -Force | Out-Null + foreach ($archive in $archives) { + $zip = [IO.Compression.ZipFile]::OpenRead($archive.Path) + try { + foreach ($class in $classes) { + $prefix = "$($archive.Prefix)$class/" + $selected = @($zip.Entries | Where-Object { + $_.Length -gt 0 -and $_.FullName.StartsWith($prefix, [StringComparison]::Ordinal) -and + $_.FullName.EndsWith('.tif', [StringComparison]::OrdinalIgnoreCase) + } | Sort-Object FullName | Select-Object -First $SamplesPerClass) + if ($selected.Count -ne $SamplesPerClass) { + throw "$($archive.Name) lacks $SamplesPerClass deterministic samples for $class." + } + foreach ($entry in $selected) { + $entryStream = $entry.Open() + try { $entrySha = Get-StreamSha256 $entryStream } + finally { $entryStream.Dispose() } + $sampleId = "$($archive.Split)-$class-$([IO.Path]::GetFileNameWithoutExtension($entry.Name))".ToLowerInvariant() + $sampleRoot = Join-Path $partialRoot "samples\$sampleId" + New-Item -ItemType Directory -Path $sampleRoot -Force | Out-Null + $sourcePath = Join-Path $sampleRoot 'source.png' + $decodeStream = $entry.Open() + try { + $decoded = [Drawing.Image]::FromStream($decodeStream, $true, $true) + try { + if ($decoded.Width -ne 224 -or $decoded.Height -ne 224) { + throw "Unexpected NCT-CRC patch geometry: $($entry.FullName)" + } + $canvas = [Drawing.Bitmap]::new(512, 512, [Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { + $graphics = [Drawing.Graphics]::FromImage($canvas) + try { + $graphics.CompositingMode = [Drawing.Drawing2D.CompositingMode]::SourceCopy + $graphics.CompositingQuality = [Drawing.Drawing2D.CompositingQuality]::HighQuality + $graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.PixelOffsetMode = [Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.DrawImage($decoded, [Drawing.Rectangle]::new(0, 0, 512, 512)) + } finally { $graphics.Dispose() } + $canvas.Save("$sourcePath.partial", [Drawing.Imaging.ImageFormat]::Png) + } finally { $canvas.Dispose() } + } finally { $decoded.Dispose() } + } finally { $decodeStream.Dispose() } + Move-Item -LiteralPath "$sourcePath.partial" -Destination $sourcePath + $sourceSha = Get-Sha256 $sourcePath + $sampleManifestPath = Join-Path $sampleRoot 'sample.json' + Write-JsonAtomic $sampleManifestPath ([ordered]@{ + schema = 'pathlab.evidence-sample/1' + source = "Zenodo-1214456/$($archive.Name)/$($entry.FullName)" + patientGroup = 'not-provided-by-public-patch-release' + slideId = "$($archive.Split):$sampleId" + sha256 = $sourceSha + license = 'CC-BY-4.0' + taskLabel = "gi-$($class.ToLowerInvariant())" + permittedUse = 'private-research' + bytes = (Get-Item -LiteralPath $sourcePath).Length + grandfatheredReadOnly = $false + }) + $revision = "nct-crc:$($archive.Split):$entrySha" + $tileManifestPath = Join-Path $sampleRoot 'tile-cache.json' + Write-JsonAtomic $tileManifestPath ([ordered]@{ + schema = 'pathlab.tile-cache/1' + source = [ordered]@{ + sha256 = $sourceSha; slideRevision = $revision; width = 512; height = 512 + sampleManifest = 'sample.json'; sampleManifestSha256 = Get-Sha256 $sampleManifestPath + } + tilePixels = 512; encoding = 'png'; preprocessingInput = 'rgb-srgb-uint8' + tiles = @([ordered]@{ + id = 'tile-001'; path = 'source.png'; sha256 = $sourceSha + x = 0; y = 0; width = 512; height = 512 + }) + }) + $samples.Add([ordered]@{ + id = $sampleId + split = $archive.Split + evaluationGroup = 'gi' + phenotypeGroup = $class + sourceGroup = $archive.Name + archiveSha256 = $archive.Sha256 + archiveEntry = $entry.FullName + archiveEntrySha256 = $entrySha + tileCacheManifest = "samples/$sampleId/tile-cache.json" + tileCacheManifestSha256 = Get-Sha256 $tileManifestPath + }) + $ledgerLines.Add(([ordered]@{ + sampleId = $sampleId; source = "Zenodo record 1214456/$($archive.Name)" + patientGroup = 'not-provided-by-public-patch-release'; slideGroup = $sampleId + sha256 = $entrySha; license = 'CC-BY-4.0'; permittedUse = 'private-research' + task = 'he-retrieval-qualification'; taskLabel = "gi-$($class.ToLowerInvariant())" + split = $archive.Split; sourceGroup = $archive.Name + } | ConvertTo-Json -Compress)) + } + } + } finally { $zip.Dispose() } + } + [IO.File]::WriteAllLines((Join-Path $partialRoot 'sample-ledger.jsonl'), $ledgerLines, + [Text.UTF8Encoding]::new($false)) + Write-JsonAtomic (Join-Path $partialRoot 'cohort.json') ([ordered]@{ + schema = 'pathlab.qualification-cohort/1' + cohortId = $cohortId + frozenAt = $FrozenAt + intendedUse = 'private-research-model-qualification' + executionStatus = 'ready' + qualificationStatus = 'not_evaluable' + qualificationReasons = @( + 'PATIENT_LEVEL_PATCH_MAPPING_UNAVAILABLE', + 'BREAST_LUNG_LYMPH_NODE_AND_OOD_GROUPS_ABSENT' + ) + transform = [ordered]@{ + sourcePixels = 224; outputPixels = 512; method = 'high-quality-bicubic-rgb' + } + acceptanceCriteria = [ordered]@{ + requiredEvaluationGroups = @('breast', 'gi', 'lung', 'lymph-node', 'benign-reactive') + minimumReferenceSamplesPerGroup = 20; minimumQuerySamplesPerGroup = 20 + minimumOodSamples = 20; maximumPatientOverlap = 0; maximumSlideOverlap = 0 + maximumSourceOverlap = 0; baselineId = 'color-histogram-v1' + minimumMacroRecallAt5Improvement = 0.05 + minimumMacroNdcgAt10Improvement = 0.03; minimumOodAuRoc = 0.80 + } + samples = $samples + }) + $actualBytes = Get-TreeBytes $partialRoot + if ($actualBytes -gt $estimatedBytes) { throw 'NCT-CRC cohort exceeded its derived-data reservation.' } + New-Item -ItemType Directory -Path (Split-Path -Parent $outputRoot) -Force | Out-Null + Move-Item -LiteralPath $partialRoot -Destination $outputRoot + Remove-Item -LiteralPath $reservationPath -Force +} catch { + throw +} + +$cohortPath = Join-Path $outputRoot 'cohort.json' +Write-Output "Built immutable NCT-CRC GI execution cohort: $cohortPath" +Write-Output "Samples: $($samples.Count)" +Write-Output "SHA256: $(Get-Sha256 $cohortPath)" +Write-Output 'Overall H&E qualification remains NOT_EVALUABLE until patient mapping and all frozen tissue/OOD groups exist.' diff --git a/scripts/build-tcga-lung-dinov2-cohort.ps1 b/scripts/build-tcga-lung-dinov2-cohort.ps1 new file mode 100644 index 0000000..c959986 --- /dev/null +++ b/scripts/build-tcga-lung-dinov2-cohort.ps1 @@ -0,0 +1,295 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $FrozenAt = '2026-08-23T05:00:00Z', + [long] $DerivedUsedBytes = -1, + [ValidateSet('v1','qc-remediation-v1')] [string] $SelectionProtocol = 'v1' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$datasetId = 'tcga-luad-lusc-he-20x2-v1' +$cohortId = if ($SelectionProtocol -eq 'qc-remediation-v1') { + 'tcga-luad-lusc-lung-20x2-qc-remediation-v1' +} else { 'tcga-luad-lusc-lung-20x2-v1' } +$state = [IO.Path]::GetFullPath($StateRoot) +$sourceRoot = [IO.Path]::GetFullPath((Join-Path $state "sources\$datasetId")) +$acquisitionRoot = Join-Path $state "acquisition\$datasetId" +$derivedRoot = [IO.Path]::GetFullPath((Join-Path $state 'derived')) +$outputRoot = [IO.Path]::GetFullPath((Join-Path $derivedRoot "qualification-prepared\$cohortId")) +$partialRoot = "$outputRoot.partial" +$reservationRoot = Join-Path $state 'quota\reservations\derived' +$reservationPath = Join-Path $reservationRoot "build-$cohortId.reservation" +$derivedQuotaBytes = 25GB +$estimatedBytes = 160MB + +function Get-Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value, [int] $Depth = 20) { + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, (($Value | ConvertTo-Json -Depth $Depth) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path +} +function Get-TreeBytes([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return 0L } + $sum = 0L + Get-ChildItem -LiteralPath $Path -Recurse -File | ForEach-Object { $sum += [long]$_.Length } + return $sum +} +function Find-VipsBinary([string] $Name) { + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -ne $command) { return $command.Source } + $packageRoot = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\libvips.libvips_Microsoft.Winget.Source_8wekyb3d8bbwe' + $candidate = Get-ChildItem -LiteralPath $packageRoot -Filter $Name -Recurse -File -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | Select-Object -First 1 + if ($null -eq $candidate) { throw "The pinned local libvips runtime is unavailable: $Name" } + return $candidate.FullName +} +function Invoke-Vips([string] $Executable, [string[]] $Arguments) { + & $Executable @Arguments + if ($LASTEXITCODE -ne 0) { throw "libvips failed with exit code $LASTEXITCODE." } +} +function Get-TissueCoordinate([string] $OverviewPath, [int] $SourceWidth, [int] $SourceHeight) { + $bitmap = [Drawing.Bitmap]::new($OverviewPath) + try { + $bestX = [int]($bitmap.Width / 2); $bestY = [int]($bitmap.Height / 2); $best = -1.0 + for ($y = 2; $y -lt $bitmap.Height - 2; $y += 3) { + for ($x = 2; $x -lt $bitmap.Width - 2; $x += 3) { + $pixel = $bitmap.GetPixel($x, $y) + $max = [Math]::Max($pixel.R, [Math]::Max($pixel.G, $pixel.B)) + $min = [Math]::Min($pixel.R, [Math]::Min($pixel.G, $pixel.B)) + $luminance = 0.2126 * $pixel.R + 0.7152 * $pixel.G + 0.0722 * $pixel.B + if ($luminance -lt 35 -or $luminance -gt 245) { continue } + $score = ($max - $min) + 0.20 * (245 - $luminance) + if ($score -gt $best) { $best = $score; $bestX = $x; $bestY = $y } + } + } + if ($best -lt 0) { throw 'No tissue-like location was found in the WSI overview.' } + $centerX = [long][Math]::Round(($bestX + 0.5) * $SourceWidth / $bitmap.Width) + $centerY = [long][Math]::Round(($bestY + 0.5) * $SourceHeight / $bitmap.Height) + return [pscustomobject]@{ + X = [int][Math]::Max(0, [Math]::Min($SourceWidth - 512, $centerX - 256)) + Y = [int][Math]::Max(0, [Math]::Min($SourceHeight - 512, $centerY - 256)) + } + } finally { $bitmap.Dispose() } +} +function Get-TissueCandidates([string] $OverviewPath, [int] $SourceWidth, [int] $SourceHeight) { + $bitmap = [Drawing.Bitmap]::new($OverviewPath) + try { + $candidates = [Collections.Generic.List[object]]::new() + for ($gridY = 0; $gridY -lt 8; $gridY++) { + for ($gridX = 0; $gridX -lt 8; $gridX++) { + $x0 = [int][Math]::Floor($gridX * $bitmap.Width / 8.0) + $x1 = [int][Math]::Floor(($gridX + 1) * $bitmap.Width / 8.0) + $y0 = [int][Math]::Floor($gridY * $bitmap.Height / 8.0) + $y1 = [int][Math]::Floor(($gridY + 1) * $bitmap.Height / 8.0) + $best = -1.0; $bestX = -1; $bestY = -1 + for ($y = $y0 + 2; $y -lt $y1 - 2; $y += 4) { + for ($x = $x0 + 2; $x -lt $x1 - 2; $x += 4) { + $pixel = $bitmap.GetPixel($x, $y) + $maximum = [Math]::Max($pixel.R, [Math]::Max($pixel.G, $pixel.B)) + $minimum = [Math]::Min($pixel.R, [Math]::Min($pixel.G, $pixel.B)) + $luminance = 0.2126 * $pixel.R + 0.7152 * $pixel.G + 0.0722 * $pixel.B + if ($luminance -lt 35 -or $luminance -gt 245) { continue } + $score = ($maximum - $minimum) + 0.20 * (245 - $luminance) + if ($score -gt $best) { $best=$score; $bestX=$x; $bestY=$y } + } + } + if ($bestX -ge 0) { + $centerX = [long][Math]::Round(($bestX + 0.5) * $SourceWidth / $bitmap.Width) + $centerY = [long][Math]::Round(($bestY + 0.5) * $SourceHeight / $bitmap.Height) + $candidates.Add([pscustomobject]@{ + X=[int][Math]::Max(0,[Math]::Min($SourceWidth-512,$centerX-256)) + Y=[int][Math]::Max(0,[Math]::Min($SourceHeight-512,$centerY-256)) + Score=$best + }) + } + } + } + $selected = @($candidates | Sort-Object @{Expression='Score';Descending=$true},Y,X) + return @($selected | Select-Object -First 9) + } finally { $bitmap.Dispose() } +} +function Get-TileQcScore([string] $TilePath) { + $bitmap = [Drawing.Bitmap]::new($TilePath) + try { + $tissue=0; $total=0; $saturation=0.0; $gradient=0.0; $minLum=255.0; $maxLum=0.0 + for ($y=0; $y -lt $bitmap.Height; $y+=2) { + for ($x=0; $x -lt $bitmap.Width; $x+=2) { + $pixel=$bitmap.GetPixel($x,$y); $total++ + $maximum=[Math]::Max($pixel.R,[Math]::Max($pixel.G,$pixel.B)) + $minimum=[Math]::Min($pixel.R,[Math]::Min($pixel.G,$pixel.B)) + $lum=0.2126*$pixel.R+0.7152*$pixel.G+0.0722*$pixel.B + if ($lum -ge 30 -and $lum -le 240 -and ($maximum-$minimum) -ge 10) { + $tissue++; $saturation += $maximum-$minimum + $minLum=[Math]::Min($minLum,$lum); $maxLum=[Math]::Max($maxLum,$lum) + } + if ($x -ge 2) { + $prior=$bitmap.GetPixel($x-2,$y) + $gradient += [Math]::Abs($pixel.R-$prior.R)+[Math]::Abs($pixel.G-$prior.G)+[Math]::Abs($pixel.B-$prior.B) + } + } + } + $fraction=$tissue/[Math]::Max(1.0,$total) + if ($fraction -lt 0.10) { return -1000.0+$fraction } + $meanSaturation=$saturation/[Math]::Max(1.0,$tissue) + $meanGradient=$gradient/[Math]::Max(1.0,$total) + return 100*$fraction+0.5*$meanSaturation+0.1*($maxLum-$minLum)+0.25*$meanGradient + } finally { $bitmap.Dispose() } +} + +try { + [void][DateTimeOffset]::ParseExact($FrozenAt, 'yyyy-MM-ddTHH:mm:ssZ', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal) +} catch { throw 'FrozenAt must be an immutable UTC timestamp.' } +if (-not $outputRoot.StartsWith($derivedRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { throw 'TCGA cohort path escaped the derived-data root.' } + +$statusPath = Join-Path $acquisitionRoot 'status.json' +$gdcPath = Join-Path $sourceRoot 'gdc-files.json' +$ledgerPath = Join-Path $sourceRoot 'sample-ledger.jsonl' +foreach ($required in @($statusPath, $gdcPath, $ledgerPath)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "TCGA acquisition is incomplete: $required" } +} +$status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json +$gdc = Get-Content -LiteralPath $gdcPath -Raw | ConvertFrom-Json +$rows = @(Get-Content -LiteralPath $ledgerPath | ForEach-Object { $_ | ConvertFrom-Json }) +if ($status.state -ne 'completed' -or $gdc.schema -ne 'pathlab.gdc-acquisition-manifest/1' -or + @($gdc.files).Count -ne 40 -or $rows.Count -ne 40 -or + @($rows.patientGroup | Sort-Object -Unique).Count -ne 40 -or + @($rows | Where-Object split -eq 'reference').Count -ne 20 -or + @($rows | Where-Object split -eq 'query').Count -ne 20 -or + @($rows | Where-Object license -ne 'NIH-GDS/NCI-GDC-open-access-policy').Count -ne 0 -or + @($rows | Where-Object permittedUse -ne 'private-research').Count -ne 0) { + throw 'The frozen TCGA source ledger is incomplete, overlapping, or unapproved.' +} +if (Test-Path -LiteralPath $outputRoot -PathType Container) { + $cohortPath = Join-Path $outputRoot 'cohort.json' + if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { throw 'Existing TCGA cohort is incomplete.' } + Write-Output "Existing immutable cohort: $cohortPath" + Write-Output "SHA256: $(Get-Sha256 $cohortPath)" + return +} +if (Test-Path -LiteralPath $partialRoot) { throw 'A partial TCGA cohort requires review before retrying.' } + +$used = if ($DerivedUsedBytes -ge 0) { $DerivedUsedBytes } else { Get-TreeBytes $derivedRoot } +$reserved = 0L +if ($DerivedUsedBytes -ge 0) { + # The caller must pause new service claims while using this trusted status snapshot. + $reservationPath = "$outputRoot.reservation" +} elseif (Test-Path -LiteralPath $reservationRoot -PathType Container) { + Get-ChildItem -LiteralPath $reservationRoot -Filter '*.reservation' -File -ErrorAction SilentlyContinue | + Where-Object FullName -ne $reservationPath | ForEach-Object { + $reserved += [long]([IO.File]::ReadAllText($_.FullName).Trim()) + } +} +if ($estimatedBytes -gt $derivedQuotaBytes - $used - $reserved) { + throw 'The 25 GB derived-data quota cannot reserve the TCGA lung cohort.' +} +New-Item -ItemType Directory -Path (Split-Path -Parent $reservationPath) -Force | Out-Null +[IO.File]::WriteAllText("$reservationPath.partial", [string]$estimatedBytes, [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath "$reservationPath.partial" -Destination $reservationPath + +$vips = Find-VipsBinary 'vips.exe' +$vipsHeader = Find-VipsBinary 'vipsheader.exe' +Add-Type -AssemblyName System.Drawing +$samples = [Collections.Generic.List[object]]::new() +try { + New-Item -ItemType Directory -Path (Join-Path $partialRoot 'samples') -Force | Out-Null + foreach ($row in @($rows | Sort-Object split, taskLabel, patientGroup, sampleId)) { + $sourceName = ([string]$row.source).Substring(([string]$row.source).LastIndexOf('/') + 1) + $source = Join-Path $sourceRoot $sourceName + if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or + (Get-Item -LiteralPath $source).Length -ne [long]$row.bytes -or + (Get-Sha256 $source) -ne [string]$row.sha256) { + throw "TCGA source checksum changed: $sourceName" + } + $width = [int]((& $vipsHeader -f width $source).Trim()) + $height = [int]((& $vipsHeader -f height $source).Trim()) + if ($LASTEXITCODE -ne 0 -or $width -lt 512 -or $height -lt 512) { + throw "TCGA source geometry is unsupported: $sourceName" + } + $sampleId = ([string]$row.sampleId).ToLowerInvariant() + $sampleRoot = Join-Path $partialRoot "samples\$sampleId" + New-Item -ItemType Directory -Path $sampleRoot -Force | Out-Null + $overview = Join-Path $sampleRoot 'overview.png' + Invoke-Vips $vips @('thumbnail', $source, $overview, '2048', '--size', 'both') + $coordinates = if ($SelectionProtocol -eq 'qc-remediation-v1') { + @(Get-TissueCandidates $overview $width $height) + } else { @(Get-TissueCoordinate $overview $width $height) } + Remove-Item -LiteralPath $overview -Force + $tilePath = Join-Path $sampleRoot 'source.png' + $evaluated = [Collections.Generic.List[object]]::new() + $candidateIndex=0 + foreach ($candidate in $coordinates) { + $candidateIndex++ + $candidatePath = Join-Path $sampleRoot ('candidate-{0:D2}.png' -f $candidateIndex) + Invoke-Vips $vips @('crop',$source,"$candidatePath.partial.png",[string]$candidate.X, + [string]$candidate.Y,'512','512') + Move-Item -LiteralPath "$candidatePath.partial.png" -Destination $candidatePath + $qcScore = if ($SelectionProtocol -eq 'qc-remediation-v1') { Get-TileQcScore $candidatePath } else { 0.0 } + $evaluated.Add([pscustomobject]@{Path=$candidatePath;X=$candidate.X;Y=$candidate.Y;Score=$qcScore}) + } + $chosen = @($evaluated | Sort-Object @{Expression='Score';Descending=$true},Y,X)[0] + Move-Item -LiteralPath $chosen.Path -Destination $tilePath + @($evaluated | Where-Object Path -ne $chosen.Path) | ForEach-Object { + Remove-Item -LiteralPath $_.Path -Force + } + $coordinate = [pscustomobject]@{X=$chosen.X;Y=$chosen.Y} + $tileSha = Get-Sha256 $tilePath + $sampleManifestPath = Join-Path $sampleRoot 'sample.json' + Write-JsonAtomic $sampleManifestPath ([ordered]@{ + schema='pathlab.evidence-sample/1'; source="$($row.source)#wsi-sha256=$($row.sha256);selector=$SelectionProtocol" + patientGroup=[string]$row.patientGroup; slideId=[string]$row.slideGroup + sha256=$tileSha; license='NIH-GDS/NCI-GDC-open-access-policy' + taskLabel=[string]$row.taskLabel; permittedUse='private-research' + bytes=[long](Get-Item -LiteralPath $tilePath).Length; grandfatheredReadOnly=$false + }) + $tileManifestPath = Join-Path $sampleRoot 'tile-cache.json' + Write-JsonAtomic $tileManifestPath ([ordered]@{ + schema='pathlab.tile-cache/1' + source=[ordered]@{ + sha256=$tileSha; slideRevision="gdc:$($row.sampleId):$($row.sha256):$SelectionProtocol" + width=$width; height=$height; sampleManifest='sample.json' + sampleManifestSha256=Get-Sha256 $sampleManifestPath + } + tilePixels=512; encoding='png'; preprocessingInput='rgb-srgb-uint8' + tiles=@([ordered]@{ + id='tile-001'; path='source.png'; sha256=$tileSha; x=$coordinate.X; y=$coordinate.Y + width=512; height=512 + }) + }) + $samples.Add([ordered]@{ + id=$sampleId; tileCacheManifest="samples/$sampleId/tile-cache.json" + tileCacheManifestSha256=Get-Sha256 $tileManifestPath; evaluationGroup='lung' + phenotypeGroup=[string]$row.taskLabel; split=[string]$row.split + sourceGroup=[string]$row.sourceGroup + }) + } + Write-JsonAtomic (Join-Path $partialRoot 'cohort.json') ([ordered]@{ + schema='pathlab.qualification-cohort/1'; cohortId=$cohortId; frozenAt=$FrozenAt + intendedUse='private-research-model-qualification' + acceptanceCriteria=[ordered]@{ + requiredEvaluationGroups=@('breast','gi','lung','lymph-node','benign-reactive') + minimumReferenceSamplesPerGroup=20; minimumQuerySamplesPerGroup=20 + minimumOodSamples=20; maximumPatientOverlap=0; maximumSlideOverlap=0 + maximumSourceOverlap=0; baselineId='color-histogram-v1' + minimumMacroRecallAt5Improvement=0.05; minimumMacroNdcgAt10Improvement=0.03 + minimumOodAuRoc=0.80 + } + samples=$samples.ToArray() + }) + if ((Get-TreeBytes $partialRoot) -gt $estimatedBytes) { throw 'TCGA cohort exceeded its reservation.' } + New-Item -ItemType Directory -Path (Split-Path -Parent $outputRoot) -Force | Out-Null + Move-Item -LiteralPath $partialRoot -Destination $outputRoot + Remove-Item -LiteralPath $reservationPath -Force +} catch { throw } + +$cohortPath = Join-Path $outputRoot 'cohort.json' +Write-Output "Built immutable TCGA lung cohort: $cohortPath" +Write-Output "Samples: $($samples.Count)" +Write-Output "SHA256: $(Get-Sha256 $cohortPath)" +Write-Output 'Overall H&E qualification remains NOT_EVALUABLE: BREAST_GI_BENIGN_REACTIVE_LYMPH_NODE_AND_OOD_GROUPS_ABSENT from this standalone cohort; fusion occurs only in a separately frozen evidence set.' diff --git a/scripts/complete-evidence-mentor-after-reboot.ps1 b/scripts/complete-evidence-mentor-after-reboot.ps1 new file mode 100644 index 0000000..f40dfbf --- /dev/null +++ b/scripts/complete-evidence-mentor-after-reboot.ps1 @@ -0,0 +1,108 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $ProgramRoot = 'C:\ProgramData\PathLab\EvidenceMentor' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$version = '2.1.1' +$taskName = 'PathLabEvidenceMentorPostRepair' +$acceptanceRoot = Join-Path $StateRoot 'acceptance\post-reboot-2.1.1' +$resultPath = Join-Path $acceptanceRoot 'result.json' +$logPath = Join-Path $acceptanceRoot 'continuation.log' +New-Item -ItemType Directory -Path $acceptanceRoot -Force | Out-Null + +function Write-Result([string] $Status, [string] $Detail) { + $value = [ordered]@{ + schema = 'pathlab.post-reboot-continuation/1' + version = $version + status = $Status + detail = $Detail + completedAt = [DateTimeOffset]::UtcNow.ToString('o') + } | ConvertTo-Json -Depth 6 + $partial = "$resultPath.partial" + [IO.File]::WriteAllText($partial, $value + "`n", [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $resultPath -Force +} + +function Set-Claims([bool] $Accepting) { + $endpoint = Get-Content -LiteralPath (Join-Path $StateRoot 'endpoint.json') -Raw | ConvertFrom-Json + $token = [IO.File]::ReadAllText((Join-Path $StateRoot 'ipc-token')).Trim() + $action = if ($Accepting) { 'resume' } else { 'pause' } + Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($endpoint.port)/v1/control/$action" ` + -Headers @{ Authorization = "Bearer $token" } -ContentType 'application/json' -Body '{}' | Out-Null +} + +function Install-OutboundDeny([string[]] $Programs) { + $desiredNames = [Collections.Generic.List[string]]::new() + $createdNames = [Collections.Generic.List[string]]::new() + try { + $index = 0 + foreach ($program in $Programs) { + if (-not (Test-Path -LiteralPath $program -PathType Leaf)) { + throw "Analysis executable is unavailable: $program" + } + $name = "PathLab Evidence Mentor outbound deny $version $index" + $desiredNames.Add($name) + if ($null -eq (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -DisplayName $name -Direction Outbound -Action Block ` + -Program $program -Profile Any -ErrorAction Stop | Out-Null + $createdNames.Add($name) + } + $actual = Get-NetFirewallRule -DisplayName $name -ErrorAction Stop | + Get-NetFirewallApplicationFilter | Select-Object -ExpandProperty Program -First 1 + if ([IO.Path]::GetFullPath($actual) -ne [IO.Path]::GetFullPath($program)) { + throw "Outbound-deny rule program mismatch: $name" + } + $index++ + } + Get-NetFirewallRule -DisplayName 'PathLab Evidence Mentor outbound deny*' -ErrorAction SilentlyContinue | + Where-Object { $desiredNames -notcontains $_.DisplayName } | Remove-NetFirewallRule + } catch { + foreach ($name in $createdNames) { + Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue | + Remove-NetFirewallRule -ErrorAction SilentlyContinue + } + throw + } +} + +Start-Transcript -LiteralPath $logPath -Append | Out-Null +try { + try { Set-Claims $false } catch { } + $runtime = Join-Path $ProgramRoot "runtime\$version" + $expectedPrograms = @((Join-Path $runtime 'jre\bin\java.exe')) + $modelRoot = Join-Path $StateRoot 'models' + if (Test-Path -LiteralPath $modelRoot) { + $expectedPrograms += Get-ChildItem -LiteralPath $modelRoot -Filter '*.exe' -File -Recurse | + Select-Object -ExpandProperty FullName + } + $expectedPrograms = @($expectedPrograms | ForEach-Object { [IO.Path]::GetFullPath($_).ToLowerInvariant() } | + Sort-Object -Unique) + Install-OutboundDeny $expectedPrograms + $rules = @(Get-NetFirewallRule -DisplayName "PathLab Evidence Mentor outbound deny $version *" -ErrorAction Stop) + $actualPrograms = @($rules | Get-NetFirewallApplicationFilter | Select-Object -ExpandProperty Program | + ForEach-Object { [IO.Path]::GetFullPath($_).ToLowerInvariant() } | Sort-Object -Unique) + if ($rules.Count -ne $expectedPrograms.Count -or + (Compare-Object -ReferenceObject $expectedPrograms -DifferenceObject $actualPrograms)) { + throw 'Installed outbound-deny rules do not exactly cover the analysis executables.' + } + + $activeVersion = Join-Path $ProgramRoot 'active-version.txt' + [IO.File]::WriteAllText("$activeVersion.partial", "$version`n", [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$activeVersion.partial" -Destination $activeVersion -Force + & (Join-Path $ProgramRoot 'Test-PathLab-Evidence-Service.ps1') -Mode Inspect ` + -ProgramRoot $ProgramRoot -StateRoot $StateRoot -ReportRoot $acceptanceRoot + if ($LASTEXITCODE -ne 0) { throw "Post-reboot service acceptance failed with exit code $LASTEXITCODE." } + + Set-Claims $true + Write-Result 'PASS' 'Firewall rules, service health, and non-reboot acceptance passed; claims resumed.' + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} catch { + try { Set-Claims $false } catch { } + Write-Result 'FAIL' $_.Exception.Message + throw +} finally { + Stop-Transcript | Out-Null +} diff --git a/scripts/evidence-mentor-service.ps1 b/scripts/evidence-mentor-service.ps1 new file mode 100644 index 0000000..777afae --- /dev/null +++ b/scripts/evidence-mentor-service.ps1 @@ -0,0 +1,301 @@ +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] [ValidateSet('Install','Upgrade','Uninstall','Status')] [string] $Action, + [string] $Version = '2.1.8', + [string] $DistributionPath, + [string] $JavaHome, + [string] $ProgramRoot = 'C:\ProgramData\PathLab\EvidenceMentor', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [switch] $ReuseStagedRuntime +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$serviceName = 'PathLabEvidenceMentor' +$winSwVersion = '2.12.0' +$winSwUri = "https://github.com/winsw/winsw/releases/download/v$winSwVersion/WinSW-x64.exe" +$winSwSha256 = '05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da' +$wrapperPath = Join-Path $ProgramRoot 'PathLabEvidenceMentor.exe' +$configPath = Join-Path $ProgramRoot 'PathLabEvidenceMentor.xml' +$activeVersionPath = Join-Path $ProgramRoot 'active-version.txt' +$runtimeRoot = Join-Path $ProgramRoot 'runtime' +$logRoot = Join-Path $StateRoot 'logs' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Administrator approval is required to install or upgrade the PathLab service.' + } +} + +function Write-AtomicText([string] $Path, [string] $Content) { + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, $Content, [Text.UTF8Encoding]::new($false)) + if (Test-Path -LiteralPath $Path -PathType Leaf) { + $backup = "$Path.backup-$([Guid]::NewGuid().ToString('N'))" + try { + [IO.File]::Replace($partial, $Path, $backup) + } finally { + if (Test-Path -LiteralPath $backup -PathType Leaf) { + Remove-Item -LiteralPath $backup -Force + } + } + } else { + [IO.File]::Move($partial, $Path) + } +} + +function Ensure-WinSW { + if (Test-Path -LiteralPath $wrapperPath -PathType Leaf) { + $actual = (Get-FileHash -LiteralPath $wrapperPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -eq $winSwSha256) { return } + throw 'Installed WinSW checksum does not match the pinned release.' + } + $partial = "$wrapperPath.partial" + Invoke-WebRequest -UseBasicParsing -Uri $winSwUri -OutFile $partial + $actual = (Get-FileHash -LiteralPath $partial -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $winSwSha256) { throw 'Downloaded WinSW checksum does not match the pinned release.' } + Move-Item -LiteralPath $partial -Destination $wrapperPath +} + +function Grant-PathLabAccess([string] $InstallingUser) { + & icacls.exe $ProgramRoot /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' "NT SERVICE\${serviceName}:(OI)(CI)RX" "${InstallingUser}:(OI)(CI)RX" | Out-Null + & icacls.exe $StateRoot /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' "NT SERVICE\${serviceName}:(OI)(CI)M" "${InstallingUser}:(RX)" | Out-Null + foreach ($operatorPath in @('inputs','requests','models','artifacts','acceptance','sources','acquisition', + 'quota\reservations\source')) { + $resolved = Join-Path $StateRoot $operatorPath + & icacls.exe $resolved /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' "NT SERVICE\${serviceName}:(OI)(CI)M" "${InstallingUser}:(OI)(CI)M" | Out-Null + } + foreach ($serviceOnlyPath in @('checkpoints','signing')) { + $resolved = Join-Path $StateRoot $serviceOnlyPath + & icacls.exe $resolved /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' "NT SERVICE\${serviceName}:(OI)(CI)M" | Out-Null + } + & icacls.exe $logRoot /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' "NT SERVICE\${serviceName}:(OI)(CI)M" "${InstallingUser}:(OI)(CI)R" | Out-Null + $tokenPath = Join-Path $StateRoot 'ipc-token' + if (Test-Path -LiteralPath $tokenPath) { + & icacls.exe $tokenPath /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' ` + "NT SERVICE\${serviceName}:R" "${InstallingUser}:R" | Out-Null + } + $endpointPath = Join-Path $StateRoot 'endpoint.json' + if (Test-Path -LiteralPath $endpointPath) { + & icacls.exe $endpointPath /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' ` + "NT SERVICE\${serviceName}:M" "${InstallingUser}:R" | Out-Null + } +} + +function Enable-ServiceSid { + & sc.exe sidtype $serviceName unrestricted | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'Windows could not enable the PathLab service SID.' + } +} + +function New-ServiceConfig([string] $RuntimePath) { + $java = [Security.SecurityElement]::Escape((Join-Path $RuntimePath 'jre\bin\java.exe')) + $classpath = [Security.SecurityElement]::Escape((Join-Path $RuntimePath 'app\lib\*')) + $state = [Security.SecurityElement]::Escape($StateRoot) + $logs = [Security.SecurityElement]::Escape($logRoot) + return @" + + $serviceName + PathLab Evidence Mentor + Offline local pathology research evidence worker and operations dashboard. + $java + -Xrs -Xmx16g -cp "$classpath" org.pathlab.forge.evidence.EvidenceMentorRunner --state "$state" --port 0 + $([Security.SecurityElement]::Escape($RuntimePath)) + Automatic + + NT AUTHORITYLocalService + + + + 1 day + 30 sec + $logs + 104857608 + + + + + +"@ +} + +function Install-FirewallRules([string] $RuntimePath) { + $executables = @((Join-Path $RuntimePath 'jre\bin\java.exe')) + $modelRoot = Join-Path $StateRoot 'models' + if (Test-Path -LiteralPath $modelRoot) { + $executables += Get-ChildItem -LiteralPath $modelRoot -Filter '*.exe' -File -Recurse | Select-Object -ExpandProperty FullName + } + $desiredNames = [Collections.Generic.List[string]]::new() + $createdNames = [Collections.Generic.List[string]]::new() + $index = 0 + try { + foreach ($executable in $executables | Select-Object -Unique) { + if (Test-Path -LiteralPath $executable -PathType Leaf) { + $name = "PathLab Evidence Mentor outbound deny $Version $index" + $desiredNames.Add($name) + $existing = Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue + if ($null -eq $existing) { + New-NetFirewallRule -DisplayName $name -Direction Outbound -Action Block ` + -Program $executable -Profile Any -ErrorAction Stop | Out-Null + $createdNames.Add($name) + } + $actualProgram = Get-NetFirewallRule -DisplayName $name -ErrorAction Stop | + Get-NetFirewallApplicationFilter | Select-Object -ExpandProperty Program -First 1 + if (-not $actualProgram -or + [IO.Path]::GetFullPath($actualProgram) -ne [IO.Path]::GetFullPath($executable)) { + throw "Outbound-deny rule does not match the staged executable: $name" + } + $index++ + } + } + if ($desiredNames.Count -eq 0) { throw 'No installed analysis executable was available for outbound deny.' } + Get-NetFirewallRule -DisplayName 'PathLab Evidence Mentor outbound deny*' -ErrorAction SilentlyContinue | + Where-Object { $desiredNames -notcontains $_.DisplayName } | Remove-NetFirewallRule + } catch { + foreach ($name in $createdNames) { + Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue + } + throw + } +} + +function Wait-RunnerHealth([int] $Seconds = 30) { + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($Seconds) + do { + Start-Sleep -Milliseconds 500 + $endpointPath = Join-Path $StateRoot 'endpoint.json' + $tokenPath = Join-Path $StateRoot 'ipc-token' + if ((Test-Path -LiteralPath $endpointPath) -and (Test-Path -LiteralPath $tokenPath)) { + try { + $endpoint = Get-Content -LiteralPath $endpointPath -Raw | ConvertFrom-Json + if ($endpoint.schema -ne 'pathlab.runner-endpoint/1') { continue } + $headers = @{ Authorization = 'Bearer ' + (Get-Content -LiteralPath $tokenPath -Raw).Trim() } + $health = Invoke-RestMethod -Uri "http://127.0.0.1:$($endpoint.port)/health" -Headers $headers -TimeoutSec 2 + if ($health.status -eq 'ready') { return $true } + } catch { } + } + } while ([DateTimeOffset]::UtcNow -lt $deadline) + return $false +} + +function Assert-DirectoryMirror([string] $Source, [string] $Target, [string] $Label) { + if (-not (Test-Path -LiteralPath $Source -PathType Container) -or + -not (Test-Path -LiteralPath $Target -PathType Container)) { + throw "$Label staging directory is incomplete." + } + $sourceRoot = [IO.Path]::GetFullPath($Source).TrimEnd('\') + $targetRoot = [IO.Path]::GetFullPath($Target).TrimEnd('\') + $sourceFiles = @(Get-ChildItem -LiteralPath $sourceRoot -File -Recurse) + $targetFiles = @(Get-ChildItem -LiteralPath $targetRoot -File -Recurse) + if ($sourceFiles.Count -ne $targetFiles.Count) { + throw "$Label staged file count does not match the requested input." + } + foreach ($sourceFile in $sourceFiles) { + $relative = $sourceFile.FullName.Substring($sourceRoot.Length).TrimStart('\') + $targetFile = Join-Path $targetRoot $relative + if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf)) { + throw "$Label staged file is missing: $relative" + } + $candidate = Get-Item -LiteralPath $targetFile + if ($candidate.Length -ne $sourceFile.Length -or + (Get-FileHash -LiteralPath $sourceFile.FullName -Algorithm SHA256).Hash -ne + (Get-FileHash -LiteralPath $candidate.FullName -Algorithm SHA256).Hash) { + throw "$Label staged file checksum does not match: $relative" + } + } +} + +function Stage-Runtime { + if (-not $DistributionPath -or -not (Test-Path -LiteralPath $DistributionPath -PathType Container)) { throw 'DistributionPath must reference a completed installDist directory.' } + if (-not $JavaHome -or -not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { throw 'JavaHome must reference a Java 17 runtime.' } + $target = Join-Path $runtimeRoot $Version + if (Test-Path -LiteralPath $target) { + # Idempotent upgrades reuse a byte-identical staged runtime. Any mismatch fails + # before the installed directory or active service configuration is touched. + Assert-DirectoryMirror $DistributionPath (Join-Path $target 'app') 'Application runtime' + Assert-DirectoryMirror $JavaHome (Join-Path $target 'jre') 'Java runtime' + return $target + } + New-Item -ItemType Directory -Path (Join-Path $target 'app') -Force | Out-Null + Copy-Item -Path (Join-Path $DistributionPath '*') -Destination (Join-Path $target 'app') -Recurse -Force + Copy-Item -LiteralPath $JavaHome -Destination (Join-Path $target 'jre') -Recurse -Force + return $target +} + +if ($Action -eq 'Status') { + Get-CimInstance Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue | Select-Object Name, State, StartMode, StartName + if (Test-Path -LiteralPath (Join-Path $StateRoot 'endpoint.json')) { Get-Content -LiteralPath (Join-Path $StateRoot 'endpoint.json') -Raw } + return +} + +if (-not $WhatIfPreference) { Assert-Administrator } +if ($Action -eq 'Uninstall') { + if ($PSCmdlet.ShouldProcess($serviceName, 'Uninstall service and outbound-deny rules while preserving state and model data')) { + if (Test-Path -LiteralPath $wrapperPath) { + & $wrapperPath stop 2>$null + & $wrapperPath uninstall + } + Get-NetFirewallRule -DisplayName 'PathLab Evidence Mentor outbound deny*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule + $shortcutPath = Join-Path ([Environment]::GetFolderPath('CommonStartMenu')) 'Programs\PathLab Evidence Mentor Dashboard.lnk' + if (Test-Path -LiteralPath $shortcutPath) { Remove-Item -LiteralPath $shortcutPath -Force } + } + return +} + +$installingUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name +if ($PSCmdlet.ShouldProcess($serviceName, "$Action autonomous service version $Version")) { + $stateDirectories = @('inputs','requests','models','artifacts','acceptance','sources','acquisition', + 'quota\reservations\source','checkpoints','signing','logs') | ForEach-Object { Join-Path $StateRoot $_ } + New-Item -ItemType Directory -Path @($ProgramRoot, $runtimeRoot, $StateRoot) -Force | Out-Null + New-Item -ItemType Directory -Path $stateDirectories -Force | Out-Null + $previousVersion = if (Test-Path -LiteralPath $activeVersionPath) { (Get-Content -LiteralPath $activeVersionPath -Raw).Trim() } else { '' } + $previousConfig = if (Test-Path -LiteralPath $configPath) { Get-Content -LiteralPath $configPath -Raw } else { '' } + $previousRuntimeVersion = $previousVersion + if ($previousConfig -and $previousConfig -match '(?i)\\runtime\\([^\\<"]+)\\') { + $previousRuntimeVersion = $Matches[1] + } + $runtimePath = Stage-Runtime + Ensure-WinSW + try { + if ($Action -eq 'Upgrade' -and (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) { & $wrapperPath stop } + Write-AtomicText $configPath (New-ServiceConfig $runtimePath) + if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) { & $wrapperPath install } + Enable-ServiceSid + Grant-PathLabAccess $installingUser + Install-FirewallRules $runtimePath + & $wrapperPath start + if (-not (Wait-RunnerHealth)) { + throw 'New runtime failed health checks.' + } + Write-AtomicText $activeVersionPath $Version + Grant-PathLabAccess $installingUser + $launcher = Join-Path $ProgramRoot 'Open-PathLab-Evidence-Dashboard.ps1' + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'open-evidence-dashboard.ps1') -Destination $launcher -Force + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'test-evidence-mentor-service.ps1') ` + -Destination (Join-Path $ProgramRoot 'Test-PathLab-Evidence-Service.ps1') -Force + $shortcutPath = Join-Path ([Environment]::GetFolderPath('CommonStartMenu')) 'Programs\PathLab Evidence Mentor Dashboard.lnk' + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = 'powershell.exe' + $shortcut.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$launcher`" -StateRoot `"$StateRoot`"" + $shortcut.WorkingDirectory = $ProgramRoot + $shortcut.Save() + } catch { + $upgradeError = $_ + & $wrapperPath stop 2>$null + if ($previousConfig) { + Write-AtomicText $configPath $previousConfig + try { Install-FirewallRules (Join-Path $runtimeRoot $previousRuntimeVersion) } catch { + Write-Warning "Previous firewall rules could not be restored: $($_.Exception.Message)" + } + & $wrapperPath start + if (Wait-RunnerHealth) { + Write-AtomicText $activeVersionPath $previousRuntimeVersion + } + } + throw "Service $Action failed and the previous runtime configuration was restored: $($upgradeError.Exception.Message)" + } +} diff --git a/scripts/evidence-mentor-task.ps1 b/scripts/evidence-mentor-task.ps1 new file mode 100644 index 0000000..c31333a --- /dev/null +++ b/scripts/evidence-mentor-task.ps1 @@ -0,0 +1,18 @@ +[CmdletBinding(SupportsShouldProcess)] +param( + [string] $RuntimeRoot, + [string] $StateRoot, + [int] $Port, + [switch] $Uninstall +) + +$ErrorActionPreference = 'Stop' +$legacyTask = 'PathLab Evidence Mentor Runner' +if ($Uninstall) { + if ($PSCmdlet.ShouldProcess($legacyTask, 'Remove the obsolete per-user scheduled task')) { + Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -ErrorAction SilentlyContinue + } + return +} + +throw 'Per-user Task Scheduler deployment was retired. Build installDist and use scripts\evidence-mentor-service.ps1 with administrator approval.' diff --git a/scripts/install-evidence-mentor-post-reboot-continuation.ps1 b/scripts/install-evidence-mentor-post-reboot-continuation.ps1 new file mode 100644 index 0000000..2ad912e --- /dev/null +++ b/scripts/install-evidence-mentor-post-reboot-continuation.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Administrator approval is required to install the post-reboot continuation.' +} + +$taskName = 'PathLabEvidenceMentorPostRepair' +$programRoot = 'C:\ProgramData\PathLab\EvidenceMentor' +$continuation = Join-Path $programRoot 'Complete-PathLab-EvidenceMentor-PostRepair.ps1' +New-Item -ItemType Directory -Path $programRoot -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'complete-evidence-mentor-after-reboot.ps1') ` + -Destination $continuation -Force +$action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$continuation`"" +$trigger = New-ScheduledTaskTrigger -AtStartup +$trigger.Delay = 'PT2M' +$taskPrincipal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30) ` + -MultipleInstances IgnoreNew +Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $taskPrincipal ` + -Settings $settings -Description 'Complete fail-closed PathLab 2.1.1 firewall and service acceptance after Windows repairs load.' ` + -Force | Out-Null +Get-ScheduledTask -TaskName $taskName | Select-Object TaskName,State diff --git a/scripts/invoke-elevated-captured.ps1 b/scripts/invoke-elevated-captured.ps1 new file mode 100644 index 0000000..ee0f760 --- /dev/null +++ b/scripts/invoke-elevated-captured.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $Script, + [Parameter(Mandatory = $true)] + [string] $Log +) + +$ErrorActionPreference = 'Stop' +try { + & ([IO.Path]::GetFullPath($Script)) *>&1 | + Out-File -LiteralPath ([IO.Path]::GetFullPath($Log)) -Encoding utf8 -Force + exit 0 +} catch { + ($_ | Out-String) | Out-File -LiteralPath ([IO.Path]::GetFullPath($Log)) -Encoding utf8 -Force + exit 1 +} diff --git a/scripts/make-dinov2-runtime-portable.ps1 b/scripts/make-dinov2-runtime-portable.ps1 new file mode 100644 index 0000000..f2b3be1 --- /dev/null +++ b/scripts/make-dinov2-runtime-portable.ps1 @@ -0,0 +1,119 @@ +[CmdletBinding(SupportsShouldProcess)] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $BasePython = 'C:\Users\enkso\AppData\Local\Programs\Python\Python312' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$modelRoot = [IO.Path]::GetFullPath((Join-Path $StateRoot 'models\he-dinov2-small-v1\1')) +$runtimeRoot = Join-Path $modelRoot 'runtime' +$portableBase = Join-Path $runtimeRoot 'base' +$partialBase = "$portableBase.partial-$PID" +$runtimeManifest = Join-Path $modelRoot 'runtime-manifest.json' +$pyvenv = Join-Path $runtimeRoot 'pyvenv.cfg' + +if (-not $modelRoot.StartsWith( + [IO.Path]::GetFullPath((Join-Path $StateRoot 'models')) + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Portable runtime target escaped the protected model root.' +} +foreach ($required in @('python.exe','python3.dll','python312.dll','vcruntime140.dll','Lib','DLLs')) { + if (-not (Test-Path -LiteralPath (Join-Path $BasePython $required))) { + throw "Python 3.12 base runtime is incomplete: $required" + } +} +if (-not (Test-Path -LiteralPath $runtimeManifest -PathType Leaf) -or + -not (Test-Path -LiteralPath $pyvenv -PathType Leaf)) { + throw 'Installed DINOv2 runtime metadata is incomplete.' +} +if (Test-Path -LiteralPath $portableBase) { + throw "Portable runtime already exists and will not be overwritten: $portableBase" +} + +function Get-Sha256([string] $Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Write-AtomicUtf8([string] $Path, [string] $Value) { + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, $Value, [Text.UTF8Encoding]::new($false)) + $backup = "$Path.backup-$([Guid]::NewGuid().ToString('N'))" + try { + [IO.File]::Replace($partial, $Path, $backup) + } finally { + if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force } + } +} + +if ($PSCmdlet.ShouldProcess($portableBase, 'Stage a checksum-ledgered portable Python 3.12 base runtime')) { + New-Item -ItemType Directory -Path $partialBase -Force | Out-Null + try { + foreach ($name in @('python.exe','pythonw.exe','python3.dll','python312.dll', + 'vcruntime140.dll','vcruntime140_1.dll','LICENSE.txt')) { + $source = Join-Path $BasePython $name + if (Test-Path -LiteralPath $source -PathType Leaf) { + Copy-Item -LiteralPath $source -Destination $partialBase + } + } + Copy-Item -LiteralPath (Join-Path $BasePython 'DLLs') -Destination $partialBase -Recurse + $targetLib = Join-Path $partialBase 'Lib' + New-Item -ItemType Directory -Path $targetLib -Force | Out-Null + Get-ChildItem -LiteralPath (Join-Path $BasePython 'Lib') -Force | + Where-Object Name -notin @('site-packages','__pycache__') | + Copy-Item -Destination $targetLib -Recurse + + Move-Item -LiteralPath $partialBase -Destination $portableBase + + $pyvenvText = @" +home = $portableBase +include-system-site-packages = false +version = 3.12.10 +executable = $portableBase\python.exe +command = portable PathLab Evidence Mentor runtime +"@ + $pyvenvPartial = "$pyvenv.portable-$PID" + [IO.File]::WriteAllText($pyvenvPartial, $pyvenvText, [Text.UTF8Encoding]::new($false)) + + $manifest = Get-Content -LiteralPath $runtimeManifest -Raw | ConvertFrom-Json + if ($manifest.schema -ne 'pathlab.model-runtime/1' -or $manifest.python -ne '3.12.10') { + throw 'Installed runtime manifest is not the pinned Python 3.12 runtime.' + } + $portableEntries = [Collections.Generic.List[object]]::new() + Get-ChildItem -LiteralPath $portableBase -File -Recurse | Sort-Object FullName | ForEach-Object { + $relative = $_.FullName.Substring($modelRoot.Length + 1).Replace('\','/') + $portableEntries.Add([ordered]@{ path = $relative; bytes = $_.Length; sha256 = Get-Sha256 $_.FullName }) + } + $portableEntries.Add([ordered]@{ + path = 'runtime/pyvenv.cfg' + bytes = (Get-Item -LiteralPath $pyvenvPartial).Length + sha256 = Get-Sha256 $pyvenvPartial + }) + $retained = @($manifest.files | Where-Object { + $_.path -ne 'runtime/pyvenv.cfg' -and -not ([string]$_.path).StartsWith('runtime/base/') + }) + $manifest.files = @($retained + $portableEntries | Sort-Object path) + $manifest | Add-Member -NotePropertyName portableBase -NotePropertyValue 'runtime/base' -Force + $manifestText = ($manifest | ConvertTo-Json -Depth 8) + "`n" + + Write-AtomicUtf8 $pyvenv $pyvenvText + Write-AtomicUtf8 $runtimeManifest $manifestText + } finally { + if (Test-Path -LiteralPath $partialBase) { + $resolved = [IO.Path]::GetFullPath($partialBase) + if (-not $resolved.StartsWith($runtimeRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to clean a path outside the installed runtime.' + } + Remove-Item -LiteralPath $resolved -Recurse -Force + } + } +} + +[pscustomobject]@{ + RuntimeRoot = $runtimeRoot + PortableBase = $portableBase + PortableFiles = @(Get-ChildItem -LiteralPath $portableBase -File -Recurse).Count + RuntimeManifestSha256 = Get-Sha256 $runtimeManifest + PyvenvSha256 = Get-Sha256 $pyvenv +} diff --git a/scripts/open-evidence-dashboard.ps1 b/scripts/open-evidence-dashboard.ps1 new file mode 100644 index 0000000..2b71ce7 --- /dev/null +++ b/scripts/open-evidence-dashboard.ps1 @@ -0,0 +1,31 @@ +[CmdletBinding()] +param([string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state') + +$ErrorActionPreference = 'Stop' +$endpointPath = Join-Path $StateRoot 'endpoint.json' +$tokenPath = Join-Path $StateRoot 'ipc-token' +if (-not (Test-Path -LiteralPath $endpointPath -PathType Leaf) -or -not (Test-Path -LiteralPath $tokenPath -PathType Leaf)) { + throw 'PathLab Evidence Mentor is not running.' +} +$endpoint = Get-Content -LiteralPath $endpointPath -Raw | ConvertFrom-Json +if ($endpoint.schema -ne 'pathlab.runner-endpoint/1' -or $endpoint.port -lt 1 -or $endpoint.port -gt 65535) { + throw 'PathLab Evidence Mentor published an invalid endpoint.' +} +$origin = "http://127.0.0.1:$($endpoint.port)" +$headers = @{ Authorization = 'Bearer ' + (Get-Content -LiteralPath $tokenPath -Raw).Trim() } +$session = Invoke-RestMethod -Method Post -Uri "$origin/v1/dashboard-sessions" -Headers $headers -TimeoutSec 2 +if ($session.code -notmatch '^[A-Za-z0-9_-]{40,80}$') { throw 'PathLab Evidence Mentor refused the dashboard session.' } +$url = "$origin/dashboard/#$($session.code)" +$browserCandidates = @( + 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe', + 'C:\Program Files\Microsoft\Edge\Application\msedge.exe', + 'C:\Program Files\Google\Chrome\Application\chrome.exe', + 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe' +) +$browser = $browserCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 +if ($browser) { + Start-Process -FilePath $browser -ArgumentList @('--new-tab', ('"' + $url + '"')) +} else { + Start-Process -FilePath 'explorer.exe' -ArgumentList ('"' + $url + '"') +} diff --git a/scripts/prepare-all-rounder-campaign.ps1 b/scripts/prepare-all-rounder-campaign.ps1 new file mode 100644 index 0000000..4e9b51a --- /dev/null +++ b/scripts/prepare-all-rounder-campaign.ps1 @@ -0,0 +1,121 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $PreparationManifest, + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$allowedHosts = @( + 'huggingface.co', 'cdn-lfs.huggingface.co', 'api.gdc.cancer.gov', + 'portal.gdc.cancer.gov', 'zenodo.org', 'www.ebi.ac.uk' +) +$limits = @{ source = 45GB; derived = 25GB; models = 10GB; evidence = 10GB } +$bucketDirectories = @{ source = 'sources'; derived = 'derived'; models = 'models'; evidence = 'artifacts' } + +function Require-ExactFields($Value, [string[]] $Names, [string] $Label) { + $actual = @($Value.PSObject.Properties.Name | Sort-Object) + $expected = @($Names | Sort-Object) + if (($actual -join "`n") -ne ($expected -join "`n")) { throw "$Label fields are invalid." } +} + +function File-Sha256([string] $Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +$preparationPath = [IO.Path]::GetFullPath($PreparationManifest) +if (-not (Test-Path -LiteralPath $preparationPath -PathType Leaf)) { + throw 'Preparation manifest is unavailable.' +} +$spec = Get-Content -LiteralPath $preparationPath -Raw | ConvertFrom-Json +Require-ExactFields $spec @('schema','campaignManifest','sampleLedger','artifacts') 'Preparation manifest' +if ($spec.schema -ne 'pathlab.campaign-preparation/1') { throw 'Preparation schema is unsupported.' } +$base = Split-Path -Parent $preparationPath +$campaignPath = [IO.Path]::GetFullPath((Join-Path $base $spec.campaignManifest)) +$ledgerPath = [IO.Path]::GetFullPath((Join-Path $base $spec.sampleLedger)) +if (-not $campaignPath.StartsWith($base, [StringComparison]::OrdinalIgnoreCase) -or + -not $ledgerPath.StartsWith($base, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Preparation inputs must stay inside the preparation directory.' +} +if (-not (Test-Path -LiteralPath $campaignPath -PathType Leaf) -or + -not (Test-Path -LiteralPath $ledgerPath -PathType Leaf)) { + throw 'Frozen campaign manifest or sample ledger is unavailable.' +} + +$usage = @{ source = 0L; derived = 0L; models = 0L; evidence = 0L } +foreach ($artifact in @($spec.artifacts)) { + Require-ExactFields $artifact @('id','url','sha256','destination','bucket','license','permittedUse','gated') 'Acquisition artifact' + if ($artifact.id -notmatch '^[A-Za-z0-9._-]{1,120}$' -or $artifact.sha256 -notmatch '^[a-f0-9]{64}$') { + throw 'Acquisition artifact identity is invalid.' + } + if (-not $limits.ContainsKey([string]$artifact.bucket) -or + [string]::IsNullOrWhiteSpace($artifact.license) -or + $artifact.permittedUse -notin @('private-research','research-restricted','benchmark-only')) { + throw 'Acquisition artifact rights or quota bucket is invalid.' + } + $uri = [Uri]$artifact.url + if ($uri.Scheme -ne 'https' -or $uri.Host -notin $allowedHosts) { + throw "Acquisition source is not allowlisted: $($uri.Host)" + } + $bucketRoot = Join-Path $StateRoot $bucketDirectories[[string]$artifact.bucket] + $destination = [IO.Path]::GetFullPath((Join-Path $bucketRoot ([string]$artifact.destination))) + if (-not $destination.StartsWith([IO.Path]::GetFullPath($bucketRoot), [StringComparison]::OrdinalIgnoreCase)) { + throw 'Acquisition destination escapes its quota bucket.' + } + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { + $headers = @{} + if ([bool]$artifact.gated) { + if ([string]::IsNullOrWhiteSpace($env:HF_TOKEN)) { throw "Gated terms/token required for $($artifact.id)." } + $headers.Authorization = "Bearer $env:HF_TOKEN" + } + $partial = "$destination.partial" + try { Invoke-WebRequest -UseBasicParsing -Uri $uri -Headers $headers -OutFile $partial } + catch { Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue; throw } + if ((File-Sha256 $partial) -ne $artifact.sha256) { + Remove-Item -LiteralPath $partial -Force + throw "Checksum mismatch for $($artifact.id)." + } + Move-Item -LiteralPath $partial -Destination $destination + } elseif ((File-Sha256 $destination) -ne $artifact.sha256) { + throw "Existing artifact checksum mismatch for $($artifact.id)." + } + $usage[[string]$artifact.bucket] += (Get-Item -LiteralPath $destination).Length +} +foreach ($bucket in $limits.Keys) { + if ($usage[$bucket] -gt $limits[$bucket]) { throw "$bucket acquisition quota exceeded." } +} + +$sampleCount = 0 +foreach ($line in Get-Content -LiteralPath $ledgerPath) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $sample = $line | ConvertFrom-Json + Require-ExactFields $sample @('sampleId','source','patientGroup','slideGroup','sha256','license','permittedUse','task','split') 'Sample ledger record' + if ($sample.sha256 -notmatch '^[a-f0-9]{64}$' -or [string]::IsNullOrWhiteSpace($sample.patientGroup) -or + [string]::IsNullOrWhiteSpace($sample.slideGroup) -or [string]::IsNullOrWhiteSpace($sample.license)) { + throw 'Sample provenance is incomplete; candidate is NOT_EVALUABLE.' + } + $sampleCount++ +} +if ($sampleCount -lt 1) { throw 'Sample ledger is empty; campaign is NOT_EVALUABLE.' } + +$campaign = Get-Content -LiteralPath $campaignPath -Raw | ConvertFrom-Json +if ($campaign.schema -ne 'pathlab.qualification-campaign/1' -or $campaign.maxRemediationAttempts -ne 1) { + throw 'Frozen campaign manifest is invalid.' +} +$freeze = [ordered]@{ + schema = 'pathlab.campaign-freeze/1' + campaignManifest = $campaignPath + campaignManifestSha256 = File-Sha256 $campaignPath + sampleLedgerSha256 = File-Sha256 $ledgerPath + sampleCount = $sampleCount + acquiredBytes = $usage + frozenAt = [DateTimeOffset]::UtcNow.ToString('o') + credentialsPersisted = $false +} +$freezePath = Join-Path $base 'campaign-freeze.json' +[IO.File]::WriteAllText("$freezePath.partial", (($freeze | ConvertTo-Json -Depth 8) + "`n"), + [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath "$freezePath.partial" -Destination $freezePath -Force +Write-Host "Campaign frozen: $freezePath" +Write-Host 'Credentials were used only by this interactive acquisition process and were not persisted.' diff --git a/scripts/prepare-evidence-mentor-2.1.0-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.0-upgrade.ps1 new file mode 100644 index 0000000..4b49b86 --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.0-upgrade.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') -Filter 'java.exe' -File -Recurse ` + -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.0 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.0.ps1' } +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.0' `` + -DistributionPath '$($distribution.Replace("'", "''"))' `` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" +Write-Host 'Run it once from an Administrator PowerShell. No reboot is requested.' diff --git a/scripts/prepare-evidence-mentor-2.1.1-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.1-upgrade.ps1 new file mode 100644 index 0000000..0e435b7 --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.1-upgrade.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') -Filter 'java.exe' -File -Recurse ` + -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.1 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.1.ps1' } +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.1' ``` + -DistributionPath '$($distribution.Replace("'", "''"))' ``` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" +Write-Host 'Run it once from an Administrator PowerShell. No reboot is requested.' diff --git a/scripts/prepare-evidence-mentor-2.1.2-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.2-upgrade.ps1 new file mode 100644 index 0000000..ee79b40 --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.2-upgrade.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') -Filter 'java.exe' -File -Recurse ` + -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.2 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.2.ps1' } +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.2' ``` + -DistributionPath '$($distribution.Replace("'", "''"))' ``` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" +Write-Host 'Run it once from an Administrator PowerShell. No reboot is requested.' diff --git a/scripts/prepare-evidence-mentor-2.1.3-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.3-upgrade.ps1 new file mode 100644 index 0000000..9a4ac50 --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.3-upgrade.ps1 @@ -0,0 +1,39 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') ` + -Filter 'java.exe' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.3 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { + $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.3.ps1' +} +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.3' ``` + -DistributionPath '$($distribution.Replace("'", "''"))' ``` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" +Write-Host 'Run it once from an Administrator PowerShell after the firewall provider recovers.' diff --git a/scripts/prepare-evidence-mentor-2.1.4-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.4-upgrade.ps1 new file mode 100644 index 0000000..f93f950 --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.4-upgrade.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') ` + -Filter 'java.exe' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.4 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { + $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.4.ps1' +} +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.4' ``` + -DistributionPath '$($distribution.Replace("'", "''"))' ``` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" diff --git a/scripts/prepare-evidence-mentor-2.1.6-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.6-upgrade.ps1 new file mode 100644 index 0000000..0a6df29 --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.6-upgrade.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') ` + -Filter 'java.exe' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.6 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { + $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.6.ps1' +} +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.6' ``` + -DistributionPath '$($distribution.Replace("'", "''"))' ``` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" diff --git a/scripts/prepare-evidence-mentor-2.1.7-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.7-upgrade.ps1 new file mode 100644 index 0000000..c3920c3 --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.7-upgrade.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') ` + -Filter 'java.exe' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.7 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { + $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.7.ps1' +} +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.7' ``` + -DistributionPath '$($distribution.Replace("'", "''"))' ``` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" diff --git a/scripts/prepare-evidence-mentor-2.1.8-upgrade.ps1 b/scripts/prepare-evidence-mentor-2.1.8-upgrade.ps1 new file mode 100644 index 0000000..2ec955d --- /dev/null +++ b/scripts/prepare-evidence-mentor-2.1.8-upgrade.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param( + [string] $JavaHome = '', + [string] $OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +if (-not $JavaHome) { + $candidate = Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\jdks') ` + -Filter 'java.exe' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '17' } | Select-Object -First 1 + if ($null -eq $candidate) { throw 'Provide -JavaHome for a Java 17 runtime.' } + $JavaHome = Split-Path -Parent (Split-Path -Parent $candidate.FullName) +} +if (-not (Test-Path -LiteralPath (Join-Path $JavaHome 'bin\java.exe') -PathType Leaf)) { + throw 'JavaHome must contain bin\java.exe.' +} + +& (Join-Path $repository 'gradlew.bat') clean test installDist +if ($LASTEXITCODE -ne 0) { throw 'The 2.1.8 distribution did not pass its build and tests.' } + +if (-not $OutputPath) { + $OutputPath = Join-Path $repository 'build\Upgrade-PathLab-EvidenceMentor-2.1.8.ps1' +} +$distribution = Join-Path $repository 'build\install\pathlab-forge' +$installer = Join-Path $repository 'scripts\evidence-mentor-service.ps1' +$content = @" +`$ErrorActionPreference = 'Stop' +& '$($installer.Replace("'", "''"))' -Action Upgrade -Version '2.1.8' ``` + -DistributionPath '$($distribution.Replace("'", "''"))' ``` + -JavaHome '$($JavaHome.Replace("'", "''"))' +"@ +$parent = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $parent -Force | Out-Null +[IO.File]::WriteAllText($OutputPath, $content, [Text.UTF8Encoding]::new($false)) +Write-Host "Elevated upgrade launcher created: $OutputPath" diff --git a/scripts/prepare-tumorquantai-ihc-source.ps1 b/scripts/prepare-tumorquantai-ihc-source.ps1 new file mode 100644 index 0000000..0dc07a9 --- /dev/null +++ b/scripts/prepare-tumorquantai-ihc-source.ps1 @@ -0,0 +1,105 @@ +[CmdletBinding()] +param( + [ValidateSet('Prepare','Status')] [string] $Action = 'Prepare', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$recordId = '21797920' +$datasetId = 'tumorquantai-breast-ihc-manifest-v1' +$bundleName = 'TQA_BreastIHC_manifest_bundle.zip' +$bundleMd5 = 'e85d64ab3d37f94469a6c507ef3fea88' +$maximumBundleBytes = 2MB +$state = [IO.Path]::GetFullPath($StateRoot) +$root = Join-Path $state "acquisition\$datasetId" +$statusPath = Join-Path $root 'status.json' +$recordPath = Join-Path $root 'zenodo-record.json' +$bundlePath = Join-Path $root $bundleName +$extractRoot = [IO.Path]::GetFullPath((Join-Path $root 'manifest')).TrimEnd('\') + +function Write-JsonAtomic([string] $Path, [object] $Value) { + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path -Force +} + +function Write-Status([string] $StateValue, [long] $Completed, [long] $Total, [string] $Detail) { + Write-JsonAtomic $statusPath ([ordered]@{ + schema='pathlab.acquisition-status/1';datasetId=$datasetId;state=$StateValue + completedBytes=$Completed;totalBytes=$Total;detail=$Detail + networkContext='interactive-user-acquisition-only';analysisNetwork='disabled' + updatedAt=[DateTimeOffset]::UtcNow.ToString('o') + }) +} + +if ($Action -eq 'Status') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { Get-Content -LiteralPath $statusPath -Raw } + else { '{"schema":"pathlab.acquisition-status/1","state":"not_started"}' } + return +} + +New-Item -ItemType Directory -Path $root -Force | Out-Null +Write-Status 'validating' 0 $maximumBundleBytes 'Validating the pinned public metadata bundle.' +$record = Invoke-RestMethod -Uri "https://zenodo.org/api/records/$recordId" -TimeoutSec 60 +if ([string]$record.id -ne $recordId -or $record.metadata.doi -ne '10.5281/zenodo.21797920' -or + $record.metadata.license.id -ne 'cc-by-4.0') { + throw 'TumorQuantAI Zenodo identity or dataset license changed.' +} +$bundle = @($record.files | Where-Object key -eq $bundleName) +if ($bundle.Count -ne 1 -or $bundle[0].checksum -ne "md5:$bundleMd5" -or + [long]$bundle[0].size -le 0 -or [long]$bundle[0].size -gt $maximumBundleBytes) { + throw 'TumorQuantAI manifest bundle identity, checksum, or bounded size changed.' +} +Write-JsonAtomic $recordPath $record +$expectedBytes = [long]$bundle[0].size +if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf) -or + (Get-Item -LiteralPath $bundlePath).Length -ne $expectedBytes -or + (Get-FileHash -LiteralPath $bundlePath -Algorithm MD5).Hash.ToLowerInvariant() -ne $bundleMd5) { + $partial = "$bundlePath.partial" + Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue + Write-Status 'transferring' 0 $expectedBytes 'Downloading the pinned metadata bundle.' + $url = "https://zenodo.org/api/records/$recordId/files/$([Uri]::EscapeDataString($bundleName))/content" + & (Get-Command curl.exe -ErrorAction Stop).Source '--fail' '--location' '--silent' '--show-error' ` + '--remove-on-error' '--output' $partial $url + if ($LASTEXITCODE -ne 0) { throw "TumorQuantAI manifest download failed with curl exit $LASTEXITCODE." } + if ((Get-Item -LiteralPath $partial).Length -ne $expectedBytes -or + (Get-FileHash -LiteralPath $partial -Algorithm MD5).Hash.ToLowerInvariant() -ne $bundleMd5) { + throw 'TumorQuantAI manifest bundle checksum validation failed.' + } + Move-Item -LiteralPath $partial -Destination $bundlePath -Force +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$partialExtract = "$extractRoot.partial" +Remove-Item -LiteralPath $partialExtract -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $partialExtract -Force | Out-Null +$archive = [IO.Compression.ZipFile]::OpenRead($bundlePath) +try { + foreach ($entry in $archive.Entries) { + if ([string]::IsNullOrWhiteSpace($entry.Name)) { continue } + $target = [IO.Path]::GetFullPath((Join-Path $partialExtract $entry.FullName)) + if (-not $target.StartsWith($extractRoot + '.partial' + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'TumorQuantAI manifest archive contains an unsafe path.' + } + New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null + [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) + } +} finally { $archive.Dispose() } +if (Test-Path -LiteralPath $extractRoot) { Remove-Item -LiteralPath $extractRoot -Recurse -Force } +Move-Item -LiteralPath $partialExtract -Destination $extractRoot +$sha = (Get-FileHash -LiteralPath $bundlePath -Algorithm SHA256).Hash.ToLowerInvariant() +Write-JsonAtomic (Join-Path $root 'rights-and-integrity.json') ([ordered]@{ + schema='pathlab.source-rights-review/1';datasetId=$datasetId + source='Zenodo record 21797920';doi='10.5281/zenodo.21797920' + datasetLicense='CC-BY-4.0';softwareAndModelsSeparate=$true + permittedUse='private-research-descriptive-only';bundleSha256=$sha + clinicalGroundTruth=$false;crossSectionCellCorrespondence=$false + qualificationStatus='not_evaluable';reason='CASE_SUBSET_AND_REFERENCE_PROTOCOL_REVIEW_REQUIRED' +}) +Write-Status 'manifest_review_required' $expectedBytes $expectedBytes ` + 'Pinned manifest ready; no case archive is approved or downloaded yet.' +Get-Content -LiteralPath $statusPath -Raw diff --git a/scripts/probe-hovernet-fast-monusac.ps1 b/scripts/probe-hovernet-fast-monusac.ps1 new file mode 100644 index 0000000..164206d --- /dev/null +++ b/scripts/probe-hovernet-fast-monusac.ps1 @@ -0,0 +1,100 @@ +[CmdletBinding()] +param([string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state') + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$state = [IO.Path]::GetFullPath($StateRoot) +$repository = Split-Path -Parent $PSScriptRoot +& (Join-Path $PSScriptRoot 'build-hovernet-fast-monusac-pack.ps1') -StateRoot $state +$packRoot = Join-Path $state 'models\cell-hovernet-fast-monusac-v1\7' +$runtimeRoot = Join-Path $state 'models\he-dinov2-small-v1\1' +$python = Join-Path $runtimeRoot 'runtime\Scripts\python.exe' +$worker = Join-Path $packRoot 'worker.py' +$probeId = 'hovernet-runtime-probe-' + [DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss') +$probeRoot = Join-Path $state "acquisition\hovernet-fast-monusac-v1\runtime-probes\$probeId" +$imagePath = Join-Path $probeRoot 'synthetic-he-runtime-fixture.png' +$requestPath = Join-Path $probeRoot 'request.json' +$partialResult = Join-Path $probeRoot 'result.json.partial' +$resultPath = Join-Path $probeRoot 'result.json' + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} + +foreach ($required in @($python,$worker,(Join-Path $packRoot 'runtime-reference.json'))) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required HoVer-Net probe input is unavailable: $required" } +} +$gpuName = (& nvidia-smi.exe --query-gpu=name --format=csv,noheader 2>$null | Select-Object -First 1).Trim() +if ($gpuName -notin @('NVIDIA Quadro P2000','Quadro P2000')) { + throw "NVIDIA Quadro P2000 is required for this runtime probe; found '$gpuName'." +} + +New-Item -ItemType Directory -Path $probeRoot -Force | Out-Null +Add-Type -AssemblyName System.Drawing +$bitmap = New-Object Drawing.Bitmap 256,256 +$graphics = [Drawing.Graphics]::FromImage($bitmap) +try { + $graphics.Clear([Drawing.Color]::FromArgb(246,225,232)) + $pink = New-Object Drawing.SolidBrush ([Drawing.Color]::FromArgb(222,147,178)) + $purple = New-Object Drawing.SolidBrush ([Drawing.Color]::FromArgb(78,42,119)) + try { + $graphics.FillRectangle($pink, 8, 8, 240, 240) + for ($row = 0; $row -lt 8; $row++) { + for ($column = 0; $column -lt 9; $column++) { + $x = 18 + $column * 25 + (($row % 2) * 7) + $y = 20 + $row * 27 + $graphics.FillEllipse($purple, $x, $y, 10 + (($row + $column) % 4), 14) + } + } + } finally { $pink.Dispose(); $purple.Dispose() } + $bitmap.Save($imagePath, [Drawing.Imaging.ImageFormat]::Png) +} finally { $graphics.Dispose(); $bitmap.Dispose() } + +Write-JsonAtomic $requestPath ([ordered]@{ + schema='pathlab.hovernet-runtime-probe/1';scope='runtime-probe-only-not-qualification' + imagePath=$imagePath;imageSha256=(Sha256 $imagePath);syntheticFixture=$true + expectedHost='NVIDIA Quadro P2000';analysisNetwork='disabled' +}) + +$prior = [ordered]@{ + PATHLAB_ANALYSIS_NETWORK=$env:PATHLAB_ANALYSIS_NETWORK;HF_HUB_OFFLINE=$env:HF_HUB_OFFLINE + TRANSFORMERS_OFFLINE=$env:TRANSFORMERS_OFFLINE;PATHLAB_MAX_VRAM_MIB=$env:PATHLAB_MAX_VRAM_MIB + PATHLAB_MAX_RAM_MIB=$env:PATHLAB_MAX_RAM_MIB;CUBLAS_WORKSPACE_CONFIG=$env:CUBLAS_WORKSPACE_CONFIG +} +try { + $env:PATHLAB_ANALYSIS_NETWORK = 'disabled' + $env:HF_HUB_OFFLINE = '1' + $env:TRANSFORMERS_OFFLINE = '1' + $env:PATHLAB_MAX_VRAM_MIB = '4608' + $env:PATHLAB_MAX_RAM_MIB = '16384' + $env:CUBLAS_WORKSPACE_CONFIG = ':4096:8' + & $python $worker --request $requestPath --output $partialResult --offline + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $partialResult -PathType Leaf)) { + throw 'HoVer-Net offline worker probe failed.' + } +} finally { + foreach ($name in $prior.Keys) { + if ($null -eq $prior[$name]) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$name" $prior[$name] } + } +} +$result = Get-Content -LiteralPath $partialResult -Raw | ConvertFrom-Json +if ($result.schema -ne 'pathlab.hovernet-runtime-probe-result/1' -or $result.status -ne 'completed' -or + $result.scope -ne 'runtime-probe-only-not-qualification' -or $result.activationEligible -ne $false -or + $result.runtime.device -notin @('NVIDIA Quadro P2000','Quadro P2000') -or $result.runtime.architecture -ne 'sm_61' -or + $result.runtime.cuda -ne '12.6' -or $result.runtime.analysisNetwork -ne 'disabled' -or + [double]$result.runtime.peakVramMiB -gt 4608 -or [double]$result.runtime.peakRamMiB -gt 16384) { + throw 'HoVer-Net runtime probe result failed its fixed acceptance contract.' +} +Move-Item -LiteralPath $partialResult -Destination $resultPath +[pscustomobject]@{ + ProbeId=$probeId;Status=$result.status;Scope=$result.scope;InstanceCount=$result.instanceCount + Device=$result.runtime.device;Cuda=$result.runtime.cuda;PeakVramMiB=$result.runtime.peakVramMiB + PeakRamMiB=$result.runtime.peakRamMiB;ElapsedSeconds=$result.runtime.elapsedSeconds + ResultPath=$resultPath;ResultSha256=(Sha256 $resultPath) +} diff --git a/scripts/repair-evidence-mentor-installed-state.ps1 b/scripts/repair-evidence-mentor-installed-state.ps1 new file mode 100644 index 0000000..a8b8181 --- /dev/null +++ b/scripts/repair-evidence-mentor-installed-state.ps1 @@ -0,0 +1,70 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $ProgramRoot = 'C:\ProgramData\PathLab\EvidenceMentor' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Administrator approval is required to repair the installed service metadata.' +} + +$configPath = Join-Path $ProgramRoot 'PathLabEvidenceMentor.xml' +$endpointPath = Join-Path $StateRoot 'endpoint.json' +$tokenPath = Join-Path $StateRoot 'ipc-token' +foreach ($required in @($configPath, $endpointPath, $tokenPath)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Required installed-state file is unavailable: $required" + } +} + +$config = Get-Content -LiteralPath $configPath -Raw +if ($config -notmatch '(?i)\\runtime\\([^\\<"]+)\\') { + throw 'The installed service configuration does not identify a versioned runtime.' +} +$runtimeVersion = $Matches[1] +$endpoint = Get-Content -LiteralPath $endpointPath -Raw | ConvertFrom-Json +$token = [IO.File]::ReadAllText($tokenPath).Trim() +$status = Invoke-RestMethod -Uri "http://127.0.0.1:$($endpoint.port)/v1/status" ` + -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 5 +if ($endpoint.schema -ne 'pathlab.runner-endpoint/1' -or $endpoint.serviceVersion -ne $runtimeVersion -or + $status.serviceVersion -ne $runtimeVersion -or $status.status -ne 'ready') { + throw 'The runtime XML, endpoint, and live service version do not agree.' +} + +$activeVersionPath = Join-Path $ProgramRoot 'active-version.txt' +$activePartial = "$activeVersionPath.partial" +[IO.File]::WriteAllText($activePartial, "$runtimeVersion`n", [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath $activePartial -Destination $activeVersionPath -Force + +$launcherSource = Join-Path $PSScriptRoot 'open-evidence-dashboard.ps1' +$launcherTarget = Join-Path $ProgramRoot 'Open-PathLab-Evidence-Dashboard.ps1' +Copy-Item -LiteralPath $launcherSource -Destination $launcherTarget -Force + +& (Join-Path $PSScriptRoot 'install-evidence-mentor-post-reboot-continuation.ps1') +$installedTask = Get-ScheduledTask -TaskName 'PathLabEvidenceMentorPostRepair' -ErrorAction Stop +if ($installedTask.Principal.UserId -ne 'SYSTEM' -or + [string]$installedTask.Principal.RunLevel -ne 'Highest') { + throw 'The post-reboot continuation task identity or run level is invalid.' +} + +$resultRoot = Join-Path $StateRoot 'acceptance\installed-state-repair' +New-Item -ItemType Directory -Path $resultRoot -Force | Out-Null +$resultPath = Join-Path $resultRoot 'result.json' +$result = [ordered]@{ + schema = 'pathlab.installed-state-repair/1' + status = 'PASS' + runtimeVersion = $runtimeVersion + launcherSha256 = (Get-FileHash -LiteralPath $launcherTarget -Algorithm SHA256).Hash.ToLowerInvariant() + postRebootTask = 'PathLabEvidenceMentorPostRepair' + postRebootTaskState = [string]$installedTask.State + repairedAt = [DateTimeOffset]::UtcNow.ToString('o') +} +$partial = "$resultPath.partial" +[IO.File]::WriteAllText($partial, (($result | ConvertTo-Json -Depth 6) + "`n"), + [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath $partial -Destination $resultPath -Force +$result diff --git a/scripts/stage-all-rounder-candidate-preflight.ps1 b/scripts/stage-all-rounder-candidate-preflight.ps1 new file mode 100644 index 0000000..c513ba8 --- /dev/null +++ b/scripts/stage-all-rounder-candidate-preflight.ps1 @@ -0,0 +1,75 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CampaignId = 'all-rounder-candidate-preflight-20260822-v1' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +$state = [IO.Path]::GetFullPath($StateRoot) +$campaignParent = [IO.Path]::GetFullPath((Join-Path $state 'acceptance\campaigns')) +$root = [IO.Path]::GetFullPath((Join-Path $campaignParent $CampaignId)) +if (-not $root.StartsWith($campaignParent + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { throw 'Campaign path escaped the protected state root.' } + +function Sha256([string] $Path) { (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } +function WriteJson([string] $Path, [object] $Value) { + [IO.File]::WriteAllText($Path, (($Value | ConvertTo-Json -Depth 16) + "`n"), + [Text.UTF8Encoding]::new($false)) +} + +$protocol = Join-Path $repository 'docs\evidence\candidate-preflight-protocol-v1.md' +$protocolSha = Sha256 $protocol +$specs = @( + @{ id='he-dinov2-small-v1'; capability='he-evidence'; scope='deployment' }, + @{ id='he-hibou-b-v1'; capability='he-evidence'; scope='deployment' }, + @{ id='he-gigapath-benchmark-v1'; capability='he-evidence'; scope='local-benchmark' }, + @{ id='cell-hovernet-fast-v1'; capability='cell-morphology'; scope='deployment' }, + @{ id='cell-pathosam-benchmark-v1'; capability='cell-morphology'; scope='local-benchmark' }, + @{ id='tutor-qwen3-0.6b-int4-v1'; capability='grounded-tutor'; scope='deployment' }, + @{ id='atlas-he-72m-v1'; capability='atlas-distillation'; scope='deployment' } +) + +New-Item -ItemType Directory -Path $root -Force | Out-Null +$campaignPath = Join-Path $root 'campaign.json' +$tracks = [Collections.Generic.List[object]]::new() +foreach ($spec in $specs) { + $packSource = Join-Path $repository "src\main\resources\evidence-packs\$($spec.id).json" + $packTarget = Join-Path $root "$($spec.id).json" + Copy-Item -LiteralPath $packSource -Destination $packTarget -Force + $requestName = "request-$($spec.id).json" + WriteJson (Join-Path $root $requestName) ([ordered]@{ packManifest = $packTarget }) + $tracks.Add([ordered]@{ + id = $spec.id; candidateId = $spec.id; capability = $spec.capability; scope = $spec.scope + requestPath = $requestName; remediationRequestPath = $null; expectedAttestationPath = $null + protocolSha256 = $protocolSha; dependsOn = @(); required = $true + }) +} +WriteJson $campaignPath ([ordered]@{ + schema = 'pathlab.qualification-campaign/1'; campaignId = $CampaignId + createdAt = '2026-08-22T00:00:00Z'; researchOnly = $true; notDiagnostic = $true + maxRemediationAttempts = 1 + quota = [ordered]@{ sourceBytes = 45GB; derivedBytes = 25GB; modelBytes = 10GB; evidenceBytes = 10GB; reserveBytes = 10GB } + tracks = $tracks +}) + +$endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json +if ($endpoint.serviceVersion -ne '2.1.1') { throw "Service 2.1.1 is required; found $($endpoint.serviceVersion)." } +$token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +try { + try { + $response = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs/$CampaignId" -Headers @{ Authorization = "Bearer $token" } + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { throw } + $response = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs" ` + -Headers @{ Authorization = "Bearer $token" } -ContentType 'application/json' ` + -Body (@{ manifestPath = $campaignPath } | ConvertTo-Json -Compress) + } +} finally { Remove-Variable token -ErrorAction SilentlyContinue } + +[pscustomobject]@{ + CampaignId = $response.id; State = $response.state + CampaignCompleted = $response.campaignCompleted; CampaignTargetMet = $response.campaignTargetMet + ManifestPath = $campaignPath; ManifestSha256 = Sha256 $campaignPath +} diff --git a/scripts/stage-bracs-breast-dinov2-retrieval.ps1 b/scripts/stage-bracs-breast-dinov2-retrieval.ps1 new file mode 100644 index 0000000..6e3865a --- /dev/null +++ b/scripts/stage-bracs-breast-dinov2-retrieval.ps1 @@ -0,0 +1,125 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CampaignId = 'dinov2-bracs-breast-retrieval-20260824-v1', + [string] $CohortId = 'bracs-roi-breast-retrieval-20x2-v1', + [string] $ProtocolPath = 'docs\evidence\he-retrieval-qualification-protocol-v1.md' +) + +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repository=Split-Path -Parent $PSScriptRoot +$state=[IO.Path]::GetFullPath($StateRoot) +$cohortRoot=Join-Path $state "derived\qualification-prepared\$CohortId" +$cohortPath=Join-Path $cohortRoot 'cohort.json' +$modelRoot=Join-Path $state 'models\he-dinov2-small-v1\1' +$packSource=Join-Path $repository 'src\main\resources\evidence-packs\he-dinov2-small-v1.json' +$workerSource=Join-Path $repository 'src\main\resources\model-workers\dinov2-worker.py' +$packTarget=Join-Path $modelRoot 'qualification-pack.json' +$workerTarget=Join-Path $modelRoot 'worker.py' +$campaignRoot=Join-Path $state "acceptance\campaigns\$CampaignId" + +function Sha256([string]$Path){(Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()} +function Write-JsonAtomic([string]$Path,[object]$Value){ + [IO.File]::WriteAllText("$Path.partial",(($Value|ConvertTo-Json -Depth 20)+"`n"),[Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} + +foreach($required in @($packSource,$workerSource,$workerTarget,(Join-Path $modelRoot 'worker.exe'), + (Join-Path $modelRoot 'model.safetensors'),(Join-Path $modelRoot 'runtime-manifest.json'), + (Join-Path $state 'endpoint.json'),(Join-Path $state 'ipc-token'))){ + if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw "Required DINOv2 input is unavailable: $required"} +} +$endpoint=Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw|ConvertFrom-Json +if([version]$endpoint.serviceVersion -lt [version]'2.1.8'){throw 'Service 2.1.8 is required for terminal progress integrity.'} +$token=[IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +$headers=@{Authorization="Bearer $token"};$origin="http://127.0.0.1:$($endpoint.port)" +$control=Invoke-RestMethod -Method Post -Uri "$origin/v1/control/pause" -Headers $headers +$status=Invoke-RestMethod -Method Get -Uri "$origin/v1/status" -Headers $headers +if($status.queue.active -ne 0){throw 'A worker is active; BRACS staging stopped safely.'} + +$derivedRoot=Join-Path $state 'derived';$derivedAcl=Get-Acl -LiteralPath $derivedRoot;$derivedSddl=$derivedAcl.Sddl +try{ + $identity=[Security.Principal.WindowsIdentity]::GetCurrent().Name + $read=New-Object Security.AccessControl.FileSystemAccessRule($identity,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow') + $derivedAcl.AddAccessRule($read)|Out-Null;Set-Acl -LiteralPath $derivedRoot -AclObject $derivedAcl + if(-not(Test-Path -LiteralPath $cohortPath -PathType Leaf)){throw 'The BRACS breast cohort is unavailable.'} + $cohortSha=Sha256 $cohortPath;$cohort=Get-Content -LiteralPath $cohortPath -Raw|ConvertFrom-Json + if($cohort.schema -ne 'pathlab.qualification-cohort/1' -or @($cohort.samples).Count -ne 80 -or + @($cohort.samples|Where-Object split -eq 'reference').Count -ne 40 -or + @($cohort.samples|Where-Object split -eq 'query').Count -ne 40 -or + @($cohort.samples|Where-Object evaluationGroup -eq 'breast').Count -ne 40 -or + @($cohort.samples|Where-Object evaluationGroup -eq 'benign-reactive').Count -ne 40 -or + $cohort.qualificationStatus -ne 'not_evaluable' -or + 'SOURCE_HELD_OUT_NOT_MET_SINGLE_BRACS_RELEASE' -notin $cohort.qualificationReasons){ + throw 'The frozen BRACS breast cohort contract changed.' + } + $first=$cohort.samples[0];$tileManifestPath=Join-Path $cohortRoot ([string]$first.tileCacheManifest) + $tileManifest=Get-Content -LiteralPath $tileManifestPath -Raw|ConvertFrom-Json + $sourcePath=Join-Path (Split-Path -Parent $tileManifestPath) ([string]$tileManifest.tiles[0].path) + if((Sha256 $tileManifestPath) -ne [string]$first.tileCacheManifestSha256 -or + (Sha256 $sourcePath) -ne [string]$tileManifest.tiles[0].sha256){throw 'The BRACS seed tile changed.'} +}finally{ + $restored=New-Object Security.AccessControl.DirectorySecurity;$restored.SetSecurityDescriptorSddlForm($derivedSddl) + Set-Acl -LiteralPath $derivedRoot -AclObject $restored +} + +try{ + $pack=Get-Content -LiteralPath $packSource -Raw|ConvertFrom-Json + if($pack.packId -ne 'he-dinov2-small-v1' -or $pack.runtimeCompatibility.workerProtocol -ne 'pathlab.model-worker/2' -or + $pack.rights.allowedUse -ne 'private-research'){ + throw 'The DINOv2 pack contract changed.' + } + if((Sha256 $workerSource) -ne [string](@($pack.artifacts|Where-Object name -eq 'workerSource')[0].sha256)){ + throw 'The DINOv2 worker source does not match its artifact ledger.' + } + $archiveRoot=Join-Path $modelRoot 'worker-source-archive';New-Item -ItemType Directory -Path $archiveRoot -Force|Out-Null + $oldWorkerSha=Sha256 $workerTarget;$archivePath=Join-Path $archiveRoot "$oldWorkerSha.py" + if(-not(Test-Path -LiteralPath $archivePath -PathType Leaf)){ + Copy-Item -LiteralPath $workerTarget -Destination "$archivePath.partial";Move-Item "$archivePath.partial" $archivePath + } + Copy-Item -LiteralPath $workerSource -Destination "$workerTarget.partial" -Force;Move-Item "$workerTarget.partial" $workerTarget -Force + Copy-Item -LiteralPath $packSource -Destination "$packTarget.partial" -Force;Move-Item "$packTarget.partial" $packTarget -Force + + New-Item -ItemType Directory -Path $campaignRoot -Force|Out-Null + $campaignPath=Join-Path $campaignRoot 'campaign.json';$requestPath=Join-Path $campaignRoot 'request-he-dinov2-small-v1.json' + $attestationPath=Join-Path $campaignRoot 'attestation-he-dinov2-small-v1.json' + $protocol=if([IO.Path]::IsPathRooted($ProtocolPath)){$ProtocolPath}else{Join-Path $repository $ProtocolPath} + if(-not(Test-Path -LiteralPath $protocol -PathType Leaf)){throw 'The frozen H&E protocol is unavailable.'} + $protocolSha=Sha256 $protocol + Write-JsonAtomic $requestPath ([ordered]@{ + schema='pathlab.evidence-job/2';sourcePath=$sourcePath;sourceSha256=[string]$tileManifest.source.sha256 + slideRevision=[string]$tileManifest.source.slideRevision;previewPath=$sourcePath + sourceWidth=[int]$tileManifest.source.width;sourceHeight=[int]$tileManifest.source.height + packManifest=$packTarget;stain='he';marker="cohort-$cohortSha" + tileCacheManifest=$tileManifestPath;tileCacheManifestSha256=[string]$first.tileCacheManifestSha256 + qualificationCampaignManifest=$campaignPath;qualificationCohortManifest=$cohortPath + qualificationCohortManifestSha256=$cohortSha + }) + Write-JsonAtomic $campaignPath ([ordered]@{ + schema='pathlab.qualification-campaign/1';campaignId=$CampaignId;createdAt=[DateTimeOffset]::UtcNow.ToString('o') + researchOnly=$true;notDiagnostic=$true;maxRemediationAttempts=1 + quota=[ordered]@{sourceBytes=45GB;derivedBytes=25GB;modelBytes=10GB;evidenceBytes=10GB;reserveBytes=10GB} + tracks=@([ordered]@{id='he-dinov2-small-bracs-breast-v1';candidateId='he-dinov2-small-v1' + capability='he-evidence';scope='deployment';requestPath=[IO.Path]::GetFileName($requestPath) + remediationRequestPath=$null;expectedAttestationPath=[IO.Path]::GetFileName($attestationPath) + protocolSha256=$protocolSha;dependsOn=@();required=$true}) + }) + [IO.File]::WriteAllLines((Join-Path $campaignRoot 'sample-ledger.jsonl'),@(([ordered]@{ + sampleId=$CohortId;source='BRACS ROI reviewed local release';patientGroup='patient-disjoint-val-test' + slideGroup='checksum-bound-rois';sha256=$cohortSha;license='CC-BY-NC-4.0';permittedUse='private-research' + task='he-breast-retrieval-qualification';split='reference-and-query' + }|ConvertTo-Json -Compress)),[Text.UTF8Encoding]::new($false)) + $preparation=Join-Path $campaignRoot 'preparation.json' + Write-JsonAtomic $preparation ([ordered]@{schema='pathlab.campaign-preparation/1';campaignManifest='campaign.json';sampleLedger='sample-ledger.jsonl';artifacts=@()}) + & (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') -PreparationManifest $preparation -StateRoot $state + try{$response=Invoke-RestMethod -Method Get -Uri "$origin/v1/qualification-runs/$CampaignId" -Headers $headers} + catch{if($_.Exception.Response.StatusCode -ne 404){throw};$response=Invoke-RestMethod -Method Post -Uri "$origin/v1/qualification-runs" -Headers $headers -ContentType 'application/json' -Body (@{manifestPath=$campaignPath}|ConvertTo-Json -Compress)} + $control=Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers +}finally{ + if($control.acceptingJobs -ne $true){try{$control=Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers}catch{}} + Remove-Variable token,headers -ErrorAction SilentlyContinue +} +[pscustomobject]@{CampaignId=$response.id;State=$response.state;campaignCompleted=$response.campaignCompleted + campaignTargetMet=$response.campaignTargetMet;AcceptingJobs=$control.acceptingJobs;SampleCount=80 + CohortManifestSha256=$cohortSha;PermittedUse='private-research-restricted'} diff --git a/scripts/stage-dinov2-session0-acceptance.ps1 b/scripts/stage-dinov2-session0-acceptance.ps1 new file mode 100644 index 0000000..1dca62d --- /dev/null +++ b/scripts/stage-dinov2-session0-acceptance.ps1 @@ -0,0 +1,220 @@ +[CmdletBinding()] +param( + [string]$StateRoot = 'D:\PathLabData\EvidenceMentor\state' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$state = [IO.Path]::GetFullPath($StateRoot) +$modelRoot = [IO.Path]::GetFullPath((Join-Path $state 'models\he-dinov2-small-v1\1')) +$acceptanceRoot = [IO.Path]::GetFullPath((Join-Path $state 'acceptance\gpu-session0-v1')) +$manifestSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ` + '..\src\main\resources\evidence-packs\he-dinov2-small-session0-acceptance-v1.json')) +$manifestTarget = Join-Path $modelRoot 'session0-acceptance-pack.json' + +if (-not $modelRoot.StartsWith((Join-Path $state 'models') + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) -or + -not $acceptanceRoot.StartsWith((Join-Path $state 'acceptance') + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Session 0 acceptance paths escaped the protected state root.' +} +if (-not (Test-Path -LiteralPath $manifestSource -PathType Leaf)) { + throw 'Bundled Session 0 acceptance manifest is unavailable.' +} + +function Get-Sha256([string]$Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Write-Utf8([string]$Path, [string]$Value) { + [IO.File]::WriteAllText($Path, $Value, [Text.UTF8Encoding]::new($false)) +} + +function Write-Json([string]$Path, [object]$Value) { + Write-Utf8 $Path (($Value | ConvertTo-Json -Depth 12) + "`n") +} + +$manifest = Get-Content -LiteralPath $manifestSource -Raw | ConvertFrom-Json +if ($manifest.schema -ne 'pathlab.ai-pack/1' -or + $manifest.usageLimits.acceptanceOnly -ne $true -or + $manifest.rights.allowedUse -ne 'benchmark-only' -or + $manifest.validation.status -ne 'not-evaluable') { + throw 'Session 0 acceptance manifest weakened its fail-closed usage limits.' +} +$artifactFiles = @{ + model = 'model.safetensors' + config = 'config.json' + preprocessor = 'preprocessor_config.json' + worker = 'worker.exe' + workerSource = 'worker.py' + 'runtime-manifest' = 'runtime-manifest.json' +} +foreach ($artifact in $manifest.artifacts) { + if (-not $artifactFiles.ContainsKey([string]$artifact.name)) { continue } + $path = Join-Path $modelRoot $artifactFiles[[string]$artifact.name] + if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or + (Get-Sha256 $path) -ne [string]$artifact.sha256) { + throw "Pinned Session 0 artifact is unavailable or changed: $($artifact.name)" + } +} + +$targetPartial = "$manifestTarget.partial" +if (Test-Path -LiteralPath $manifestTarget -PathType Leaf) { + if ((Get-Sha256 $manifestTarget) -ne (Get-Sha256 $manifestSource)) { + $installed = Get-Content -LiteralPath $manifestTarget -Raw | ConvertFrom-Json + if ($installed.schema -ne 'pathlab.ai-pack/1' -or + $installed.packId -ne $manifest.packId -or + $installed.version -ne $manifest.version -or + $installed.usageLimits.acceptanceOnly -ne $true -or + $installed.rights.allowedUse -ne 'benchmark-only' -or + $installed.validation.status -ne 'not-evaluable') { + throw 'Refusing to replace a manifest that is not the same fail-closed acceptance pack.' + } + Copy-Item -LiteralPath $manifestSource -Destination $targetPartial + $backup = "$manifestTarget.backup-$([Guid]::NewGuid().ToString('N'))" + try { + [IO.File]::Replace($targetPartial, $manifestTarget, $backup) + } finally { + if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force } + } + } +} else { + Copy-Item -LiteralPath $manifestSource -Destination $targetPartial + Move-Item -LiteralPath $targetPartial -Destination $manifestTarget +} + +$requestPath = Join-Path $acceptanceRoot 'request.json' +if (Test-Path -LiteralPath $requestPath -PathType Leaf) { + $requestSha = Get-Sha256 $requestPath + [pscustomobject]@{ + RequestPath = $requestPath + RequestSha256 = $requestSha + JobId = 'acceptance-' + $requestSha.Substring(0, 16) + PackManifest = $manifestTarget + PackManifestSha256 = Get-Sha256 $manifestTarget + } + return +} + +$partialRoot = "$acceptanceRoot.partial-$PID" +if (Test-Path -LiteralPath $partialRoot) { + throw 'A prior Session 0 acceptance staging directory requires review.' +} +New-Item -ItemType Directory -Path (Join-Path $partialRoot 'tiles') -Force | Out-Null +try { + Add-Type -AssemblyName System.Drawing + $sourcePath = Join-Path $partialRoot 'source.png' + $source = [Drawing.Bitmap]::new(1024, 1024, [Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { + $graphics = [Drawing.Graphics]::FromImage($source) + try { + $graphics.Clear([Drawing.Color]::FromArgb(238, 204, 222)) + $brushes = @( + [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(84, 48, 132)), + [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(164, 91, 139)), + [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(111, 62, 151)), + [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(197, 119, 157)) + ) + try { + for ($quadrant = 0; $quadrant -lt 4; $quadrant++) { + $originX = ($quadrant % 2) * 512 + $originY = [Math]::Floor($quadrant / 2) * 512 + for ($y = 16; $y -lt 500; $y += 32) { + for ($x = 16; $x -lt 500; $x += 32) { + $size = 8 + (($x + $y + $quadrant) % 13) + $graphics.FillEllipse($brushes[$quadrant], $originX + $x, $originY + $y, $size, $size + 3) + } + } + } + } finally { + foreach ($brush in $brushes) { $brush.Dispose() } + } + } finally { $graphics.Dispose() } + $source.Save($sourcePath, [Drawing.Imaging.ImageFormat]::Png) + $tiles = [Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt 4; $index++) { + $x = ($index % 2) * 512 + $y = [Math]::Floor($index / 2) * 512 + $tile = $source.Clone([Drawing.Rectangle]::new($x, $y, 512, 512), + [Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { + $relative = "tiles/tile-$($index + 1).png" + $tilePath = Join-Path $partialRoot $relative + $tile.Save($tilePath, [Drawing.Imaging.ImageFormat]::Png) + $tiles.Add([ordered]@{ + id = "tile-$($index + 1)" + path = $relative + sha256 = Get-Sha256 $tilePath + x = [int]$x + y = [int]$y + width = 512 + height = 512 + }) + } finally { $tile.Dispose() } + } + } finally { $source.Dispose() } + + $sourceSha = Get-Sha256 $sourcePath + $samplePath = Join-Path $partialRoot 'sample.json' + Write-Json $samplePath ([ordered]@{ + schema = 'pathlab.evidence-sample/1' + source = 'pathlab-synthetic-session0-acceptance-v1' + patientGroup = 'synthetic-no-patient' + slideId = 'session0-p2000-v1' + sha256 = $sourceSha + license = 'PathLab synthetic acceptance fixture' + taskLabel = 'synthetic-hardware-acceptance' + permittedUse = 'private-research' + bytes = (Get-Item -LiteralPath $sourcePath).Length + grandfatheredReadOnly = $false + }) + $tileCachePath = Join-Path $partialRoot 'tile-cache.json' + Write-Json $tileCachePath ([ordered]@{ + schema = 'pathlab.tile-cache/1' + source = [ordered]@{ + sha256 = $sourceSha + slideRevision = "session0-p2000-v1:$sourceSha" + width = 1024 + height = 1024 + sampleManifest = 'sample.json' + sampleManifestSha256 = Get-Sha256 $samplePath + } + tilePixels = 512 + encoding = 'png' + preprocessingInput = 'rgb-srgb-uint8' + tiles = $tiles + }) + Write-Json (Join-Path $partialRoot 'request.json') ([ordered]@{ + schema = 'pathlab.evidence-job/2' + sourcePath = (Join-Path $acceptanceRoot 'source.png') + sourceSha256 = $sourceSha + slideRevision = "session0-p2000-v1:$sourceSha" + previewPath = (Join-Path $acceptanceRoot 'source.png') + sourceWidth = 1024 + sourceHeight = 1024 + packManifest = $manifestTarget + stain = 'he' + marker = 'generic' + tileCacheManifest = (Join-Path $acceptanceRoot 'tile-cache.json') + tileCacheManifestSha256 = Get-Sha256 $tileCachePath + }) + Move-Item -LiteralPath $partialRoot -Destination $acceptanceRoot +} finally { + if (Test-Path -LiteralPath $partialRoot) { + $resolvedPartial = [IO.Path]::GetFullPath($partialRoot) + if (-not $resolvedPartial.StartsWith((Join-Path $state 'acceptance') + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to clean a staging path outside the acceptance root.' + } + Remove-Item -LiteralPath $resolvedPartial -Recurse -Force + } +} + +$requestSha = Get-Sha256 $requestPath +[pscustomobject]@{ + RequestPath = $requestPath + RequestSha256 = $requestSha + JobId = 'acceptance-' + $requestSha.Substring(0, 16) + PackManifest = $manifestTarget + PackManifestSha256 = Get-Sha256 $manifestTarget +} diff --git a/scripts/stage-hovernet-monusac-qualification.ps1 b/scripts/stage-hovernet-monusac-qualification.ps1 new file mode 100644 index 0000000..d6d888b --- /dev/null +++ b/scripts/stage-hovernet-monusac-qualification.ps1 @@ -0,0 +1,138 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CampaignId = 'monusac-hovernet-fast-heldout-20260824-v2-remediation', + [string] $CohortId = 'monusac2020-cell-heldout-23-v1', + [string] $PackVersion = '7' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +$state = [IO.Path]::GetFullPath($StateRoot) +$derivedRoot = Join-Path $state 'derived' +$cohortRoot = Join-Path $derivedRoot "qualification-prepared\$CohortId" +$cohortPath = Join-Path $cohortRoot 'cohort.json' +$packSource = Join-Path $state "models\cell-hovernet-fast-monusac-v1\$PackVersion\pack.json" +$protocolPath = Join-Path $repository 'qualification-protocols\cell-hovernet-fast-monusac-v1.json' +$campaignRoot = Join-Path $state "acceptance\campaigns\$CampaignId" +$campaignPath = Join-Path $campaignRoot 'campaign.json' +$requestPath = Join-Path $campaignRoot 'request-cell-hovernet-fast-monusac-v1.json' +$attestationPath = Join-Path $campaignRoot 'attestation-cell-hovernet-fast-monusac-v1.json' + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 30) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} + +foreach ($required in @($packSource,$protocolPath,(Join-Path $state 'endpoint.json'),(Join-Path $state 'ipc-token'))) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Required HoVer-Net qualification input is unavailable: $required" + } +} +$endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json +if ([version]$endpoint.serviceVersion -lt [version]'2.1.8') { + throw 'Service 2.1.8 is required for final-progress and failure-attribution repair.' +} +$token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +$headers = @{Authorization="Bearer $token"} +$origin = "http://127.0.0.1:$($endpoint.port)" +$control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/pause" -Headers $headers +$status = Invoke-RestMethod -Method Get -Uri "$origin/v1/status" -Headers $headers +if ($status.queue.active -ne 0) { throw 'A worker is active; HoVer-Net staging stopped safely.' } + +$derivedAcl = Get-Acl -LiteralPath $derivedRoot +$derivedSddl = $derivedAcl.Sddl +try { + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $temporaryRead = New-Object Security.AccessControl.FileSystemAccessRule( + $currentIdentity, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $derivedAcl.AddAccessRule($temporaryRead) | Out-Null + Set-Acl -LiteralPath $derivedRoot -AclObject $derivedAcl + if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { throw 'The frozen MoNuSAC cohort is unavailable.' } + $cohortSha = Sha256 $cohortPath + $cohort = Get-Content -LiteralPath $cohortPath -Raw | ConvertFrom-Json + if ($cohort.schema -ne 'pathlab.cell-qualification-cohort/1' -or @($cohort.samples).Count -ne 23 -or + @($cohort.samples.organ | Sort-Object -Unique).Count -ne 4 -or + @($cohort.samples | Where-Object patientOverlapWithTraining -eq $true).Count -ne 0 -or + $cohort.gates.minimumMacroPq -ne 0.45 -or $cohort.gates.minimumInstanceDice -ne 0.70 -or + $cohort.gates.maximumCountError -ne 0.15 -or $cohort.gates.maximumMorphometryBias -ne 0.10 -or + $cohort.gates.maximumFailedRegionRate -ne 0.05) { + throw 'The frozen MoNuSAC cohort or gates changed.' + } + $seed = $cohort.samples[0] + $sourcePath = Join-Path $cohortRoot ([string]$seed.imagePath) + if ((Sha256 $sourcePath) -ne [string]$seed.imageSha256) { throw 'The MoNuSAC seed image changed.' } +} finally { + $restoredAcl = New-Object Security.AccessControl.DirectorySecurity + $restoredAcl.SetSecurityDescriptorSddlForm($derivedSddl) + Set-Acl -LiteralPath $derivedRoot -AclObject $restoredAcl +} + +try { + New-Item -ItemType Directory -Path $campaignRoot -Force | Out-Null + $packTarget = Join-Path $campaignRoot 'cell-hovernet-fast-monusac-v1.json' + Copy-Item -LiteralPath $packSource -Destination "$packTarget.partial" -Force + Move-Item -LiteralPath "$packTarget.partial" -Destination $packTarget -Force + $pack = Get-Content -LiteralPath $packTarget -Raw | ConvertFrom-Json + if ($pack.packId -ne 'cell-hovernet-fast-monusac-v1' -or $pack.version -ne $PackVersion -or + $pack.runtimeCompatibility.workerProtocol -ne 'pathlab.model-worker/2' -or + $pack.validation.status -ne 'not-evaluable' -or $pack.rights.allowedUse -ne 'benchmark-only') { + throw 'The installed HoVer-Net qualification pack changed.' + } + $protocolSha = Sha256 $protocolPath + Write-JsonAtomic $requestPath ([ordered]@{ + schema='pathlab.evidence-job/2';sourcePath=$sourcePath;sourceSha256=[string]$seed.imageSha256 + slideRevision="monusac:$($seed.patientGroup):$($seed.imageSha256)";previewPath=$sourcePath + sourceWidth=[int]$seed.width;sourceHeight=[int]$seed.height;packManifest=$packTarget + stain='he';marker='generic';qualificationCampaignManifest=$campaignPath + qualificationCohortManifest=$cohortPath;qualificationCohortManifestSha256=$cohortSha + }) + Write-JsonAtomic $campaignPath ([ordered]@{ + schema='pathlab.qualification-campaign/1';campaignId=$CampaignId + createdAt=[DateTimeOffset]::UtcNow.ToString('o');researchOnly=$true;notDiagnostic=$true + maxRemediationAttempts=1 + quota=[ordered]@{sourceBytes=45GB;derivedBytes=25GB;modelBytes=10GB;evidenceBytes=10GB;reserveBytes=10GB} + tracks=@([ordered]@{ + id='cell-hovernet-fast-monusac-v1';candidateId='cell-hovernet-fast-monusac-v1' + capability='cell-morphology';scope='local-benchmark';requestPath=[IO.Path]::GetFileName($requestPath) + remediationRequestPath=$null;expectedAttestationPath=[IO.Path]::GetFileName($attestationPath) + protocolSha256=$protocolSha;dependsOn=@();required=$true + }) + }) + [IO.File]::WriteAllLines((Join-Path $campaignRoot 'sample-ledger.jsonl'), @(([ordered]@{ + sampleId=$CohortId;source='Official MoNuSAC 2020 testing archive';patientGroup='23-held-out-patients' + slideGroup='23-annotation-bearing-fields';sha256=$cohortSha;license='CC-BY-NC-SA-4.0' + permittedUse='private-research-restricted';task='cell-instance-qualification';split='qualification-held-out-test' + } | ConvertTo-Json -Compress)), [Text.UTF8Encoding]::new($false)) + $preparationPath = Join-Path $campaignRoot 'preparation.json' + Write-JsonAtomic $preparationPath ([ordered]@{ + schema='pathlab.campaign-preparation/1';campaignManifest='campaign.json' + sampleLedger='sample-ledger.jsonl';artifacts=@() + }) + & (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') ` + -PreparationManifest $preparationPath -StateRoot $state + try { + $response = Invoke-RestMethod -Method Get -Uri "$origin/v1/qualification-runs/$CampaignId" -Headers $headers + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { throw } + $response = Invoke-RestMethod -Method Post -Uri "$origin/v1/qualification-runs" -Headers $headers ` + -ContentType 'application/json' -Body (@{manifestPath=$campaignPath}|ConvertTo-Json -Compress) + } + $control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers +} finally { + if ($control.acceptingJobs -ne $true) { + try { $control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers } catch { } + } + Remove-Variable token,headers -ErrorAction SilentlyContinue +} +[pscustomobject]@{ + CampaignId=$response.id;State=$response.state;campaignCompleted=$response.campaignCompleted + campaignTargetMet=$response.campaignTargetMet;AcceptingJobs=$control.acceptingJobs + SampleCount=23;CohortManifestSha256=$cohortSha;PackVersion=$PackVersion;PermittedUse='private-research-restricted' +} diff --git a/scripts/stage-local-deterministic-campaign.ps1 b/scripts/stage-local-deterministic-campaign.ps1 new file mode 100644 index 0000000..e0b5ded --- /dev/null +++ b/scripts/stage-local-deterministic-campaign.ps1 @@ -0,0 +1,149 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $BracsManifest = 'D:\PathLabData\BRACS\prepared\bracs-roi-clean-v1\manifest.csv', + [string] $BracsProvenance = 'D:\PathLabData\BRACS\prepared\bracs-roi-clean-v1\provenance.json' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +$state = [IO.Path]::GetFullPath($StateRoot) +$campaignId = 'deterministic-baselines-20260822-v1' +$root = [IO.Path]::GetFullPath((Join-Path $state "acceptance\campaigns\$campaignId")) +if (-not $root.StartsWith((Join-Path $state 'acceptance\campaigns') + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { throw 'Campaign path escaped the protected state root.' } + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function WriteJson([string] $Path, [object] $Value) { + [IO.File]::WriteAllText($Path, (($Value | ConvertTo-Json -Depth 16) + "`n"), + [Text.UTF8Encoding]::new($false)) +} + +if (-not (Test-Path -LiteralPath $BracsManifest -PathType Leaf) -or + -not (Test-Path -LiteralPath $BracsProvenance -PathType Leaf)) { + throw 'The grandfathered BRACS manifest and provenance are required.' +} +$provenance = Get-Content -LiteralPath $BracsProvenance -Raw | ConvertFrom-Json +if ($provenance.license_declared_by_current_source -ne 'CC-BY-NC-4.0' -or + $provenance.patient_disjoint -ne $true) { + throw 'BRACS rights or patient-disjoint provenance did not pass.' +} +$sample = Import-Csv -LiteralPath $BracsManifest | + Where-Object { $_.split -eq 'test' -and $_.sha256 -match '^[a-f0-9]{64}$' } | + Select-Object -First 1 +if ($null -eq $sample) { throw 'No checksum-bound BRACS test sample is available.' } +$source = Join-Path ([string]$provenance.raw_root) ([string]$sample.relative_path) +if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or (Sha256 $source) -ne $sample.sha256) { + throw 'The selected BRACS source checksum changed.' +} + +New-Item -ItemType Directory -Path $root -Force | Out-Null +$sourceTarget = Join-Path $root 'bracs-test-source.png' +if (-not (Test-Path -LiteralPath $sourceTarget)) { Copy-Item -LiteralPath $source -Destination $sourceTarget } +if ((Sha256 $sourceTarget) -ne $sample.sha256) { throw 'The staged BRACS source checksum changed.' } + +$packSpecs = @( + @{ id='cell-od-watershed-v2'; capability='cell-morphology'; stain='he'; marker='generic'; protocol='dd251775f0abe6a780b2389ee489f18bcec78b9cb80e1facfd7134a05cb0c2a0' }, + @{ id='ihc-descriptive-v2'; capability='ihc-descriptive'; stain='ihc_dab'; marker='generic'; protocol='9b2d3299eed5504fe7d89839cacae6ea5232762d6405bdfab261ad78e30b991f' }, + @{ id='special-stain-descriptive-v2'; capability='special-stain-descriptive'; stain='pas'; marker='generic'; protocol='d035efcec9812f16464b4e8e59e9d245884a57698626821ab0cf056d05854cee' }, + @{ id='cytology-descriptive-v2'; capability='cytology-descriptive'; stain='papanicolaou'; marker='generic'; protocol='b354e6c605afc55b5663167453fa2b23738f311d48de5f30293cef0ba26f962a' } +) +$campaignPath = Join-Path $root 'campaign.json' +$tracks = [Collections.Generic.List[object]]::new() +foreach ($spec in $packSpecs) { + $packSource = Join-Path $repository "src\main\resources\evidence-packs\$($spec.id).json" + $packTarget = Join-Path $root "$($spec.id).json" + Copy-Item -LiteralPath $packSource -Destination $packTarget -Force + $requestName = "request-$($spec.id).json" + $requestPath = Join-Path $root $requestName + WriteJson $requestPath ([ordered]@{ + schema = 'pathlab.evidence-job/2' + sourcePath = $sourceTarget + sourceSha256 = Sha256 $sourceTarget + slideRevision = "bracs:$($sample.slide_id):$($sample.sha256)" + previewPath = $sourceTarget + sourceWidth = [int]$sample.width + sourceHeight = [int]$sample.height + packManifest = $packTarget + stain = $spec.stain + marker = $spec.marker + qualificationCampaignManifest = $campaignPath + }) + $tracks.Add([ordered]@{ + id = $spec.id + candidateId = $spec.id + capability = $spec.capability + scope = 'deployment' + requestPath = $requestName + remediationRequestPath = $null + expectedAttestationPath = "attestation-$($spec.id).json" + protocolSha256 = $spec.protocol + dependsOn = @() + required = $true + }) +} +WriteJson $campaignPath ([ordered]@{ + schema = 'pathlab.qualification-campaign/1' + campaignId = $campaignId + createdAt = '2026-08-22T00:00:00Z' + researchOnly = $true + notDiagnostic = $true + maxRemediationAttempts = 1 + quota = [ordered]@{ + sourceBytes = 45GB; derivedBytes = 25GB; modelBytes = 10GB + evidenceBytes = 10GB; reserveBytes = 10GB + } + tracks = $tracks +}) +$ledgerPath = Join-Path $root 'sample-ledger.jsonl' +$ledgerRecord = [ordered]@{ + sampleId = [string]$sample.roi_id + source = 'BRACS_RoI/latest_version' + patientGroup = [string]$sample.patient_id + slideGroup = [string]$sample.slide_id + sha256 = [string]$sample.sha256 + license = 'CC-BY-NC-4.0' + permittedUse = 'research-restricted' + task = 'deterministic-execution-smoke-only' + split = [string]$sample.split +} +[IO.File]::WriteAllText($ledgerPath, (($ledgerRecord | ConvertTo-Json -Compress) + "`n"), + [Text.UTF8Encoding]::new($false)) +$preparationPath = Join-Path $root 'preparation.json' +WriteJson $preparationPath ([ordered]@{ + schema = 'pathlab.campaign-preparation/1' + campaignManifest = 'campaign.json' + sampleLedger = 'sample-ledger.jsonl' + artifacts = @() +}) + +& (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') ` + -PreparationManifest $preparationPath -StateRoot $state + +$endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json +$token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +try { + try { + $response = Invoke-RestMethod -Method Get ` + -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs/$campaignId" ` + -Headers @{ Authorization = "Bearer $token" } + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { throw } + $response = Invoke-RestMethod -Method Post ` + -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs" ` + -Headers @{ Authorization = "Bearer $token" } -ContentType 'application/json' ` + -Body (@{ manifestPath = $campaignPath } | ConvertTo-Json -Compress) + } +} finally { Remove-Variable token -ErrorAction SilentlyContinue } +[pscustomobject]@{ + CampaignId = $response.id + State = $response.state + CampaignCompleted = $response.campaignCompleted + CampaignTargetMet = $response.campaignTargetMet + ManifestPath = $campaignPath + ManifestSha256 = Sha256 $campaignPath + DashboardLauncher = Join-Path $repository 'scripts\open-evidence-dashboard.ps1' +} diff --git a/scripts/stage-monusac-cell-qualification.ps1 b/scripts/stage-monusac-cell-qualification.ps1 new file mode 100644 index 0000000..cde322e --- /dev/null +++ b/scripts/stage-monusac-cell-qualification.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CampaignId = 'monusac-od-watershed-heldout-20260824-v1', + [string] $CohortId = 'monusac2020-cell-heldout-23-v1' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +$state = [IO.Path]::GetFullPath($StateRoot) +$derivedRoot = Join-Path $state 'derived' +$cohortRoot = Join-Path $derivedRoot "qualification-prepared\$CohortId" +$cohortPath = Join-Path $cohortRoot 'cohort.json' +$campaignRoot = Join-Path $state "acceptance\campaigns\$CampaignId" +$campaignPath = Join-Path $campaignRoot 'campaign.json' +$requestPath = Join-Path $campaignRoot 'request-cell-od-watershed-v2.json' +$attestationPath = Join-Path $campaignRoot 'attestation-cell-od-watershed-v2.json' +$packSource = Join-Path $repository 'src\main\resources\evidence-packs\cell-od-watershed-v2.json' +$protocolPath = Join-Path $repository 'qualification-protocols\cell-od-watershed-v2.json' + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + [IO.File]::WriteAllText("$Path.partial", (($Value | ConvertTo-Json -Depth 30) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$Path.partial" -Destination $Path -Force +} + +foreach ($required in @($packSource,$protocolPath,(Join-Path $state 'endpoint.json'),(Join-Path $state 'ipc-token'))) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required qualification input is unavailable: $required" } +} +$endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json +if ([version]$endpoint.serviceVersion -lt [version]'2.1.6') { + throw 'Service 2.1.6 is required for bounded held-out cell-instance qualification.' +} +$token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +$headers = @{Authorization="Bearer $token"} +$origin = "http://127.0.0.1:$($endpoint.port)" +$control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/pause" -Headers $headers +$status = Invoke-RestMethod -Method Get -Uri "$origin/v1/status" -Headers $headers +if ($status.queue.active -ne 0) { throw 'A worker is active; MoNuSAC staging stopped safely.' } + +$derivedAcl = Get-Acl -LiteralPath $derivedRoot +$derivedSddl = $derivedAcl.Sddl +try { + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $temporaryModify = New-Object Security.AccessControl.FileSystemAccessRule( + $currentIdentity, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $derivedAcl.AddAccessRule($temporaryModify) | Out-Null + Set-Acl -LiteralPath $derivedRoot -AclObject $derivedAcl + & (Join-Path $repository 'scripts\build-monusac-cell-cohort.ps1') ` + -StateRoot $state -CohortId $CohortId -DerivedUsedBytes ([long]$status.quota.derived.usedBytes) + if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { throw 'The frozen MoNuSAC cohort is unavailable.' } + $cohortSha = Sha256 $cohortPath + $cohort = Get-Content -LiteralPath $cohortPath -Raw | ConvertFrom-Json + if ($cohort.schema -ne 'pathlab.cell-qualification-cohort/1' -or @($cohort.samples).Count -ne 23 -or + @($cohort.samples.organ | Sort-Object -Unique).Count -ne 4 -or + @($cohort.samples | Where-Object patientOverlapWithTraining -eq $true).Count -ne 0 -or + $cohort.gates.minimumMacroPq -ne 0.45 -or $cohort.gates.minimumInstanceDice -ne 0.70 -or + $cohort.gates.maximumCountError -ne 0.15 -or $cohort.gates.maximumMorphometryBias -ne 0.10 -or + $cohort.gates.maximumFailedRegionRate -ne 0.05) { + throw 'The frozen MoNuSAC cohort or gates changed.' + } + $seed = $cohort.samples[0] + $sourcePath = Join-Path $cohortRoot ([string]$seed.imagePath) + if ((Sha256 $sourcePath) -ne [string]$seed.imageSha256) { throw 'The MoNuSAC seed image changed.' } +} finally { + $restoredAcl = New-Object Security.AccessControl.DirectorySecurity + $restoredAcl.SetSecurityDescriptorSddlForm($derivedSddl) + Set-Acl -LiteralPath $derivedRoot -AclObject $restoredAcl +} + +try { + New-Item -ItemType Directory -Path $campaignRoot -Force | Out-Null + $packTarget = Join-Path $campaignRoot 'cell-od-watershed-v2.json' + Copy-Item -LiteralPath $packSource -Destination "$packTarget.partial" -Force + Move-Item -LiteralPath "$packTarget.partial" -Destination $packTarget -Force + $protocolSha = Sha256 $protocolPath + $pack = Get-Content -LiteralPath $packTarget -Raw | ConvertFrom-Json + if ($pack.packId -ne 'cell-od-watershed-v2' -or + $pack.qualificationPolicy.protocolSha256 -ne $protocolSha) { + throw 'The cell pack is not bound to the frozen MoNuSAC protocol.' + } + Write-JsonAtomic $requestPath ([ordered]@{ + schema='pathlab.evidence-job/2';sourcePath=$sourcePath;sourceSha256=[string]$seed.imageSha256 + slideRevision="monusac:$($seed.patientGroup):$($seed.imageSha256)";previewPath=$sourcePath + sourceWidth=[int]$seed.width;sourceHeight=[int]$seed.height;packManifest=$packTarget + stain='he';marker='generic';qualificationCampaignManifest=$campaignPath + qualificationCohortManifest = $cohortPath + qualificationCohortManifestSha256 = $cohortSha + }) + Write-JsonAtomic $campaignPath ([ordered]@{ + schema='pathlab.qualification-campaign/1';campaignId=$CampaignId + createdAt=[DateTimeOffset]::UtcNow.ToString('o');researchOnly=$true;notDiagnostic=$true + maxRemediationAttempts=1 + quota=[ordered]@{sourceBytes=45GB;derivedBytes=25GB;modelBytes=10GB;evidenceBytes=10GB;reserveBytes=10GB} + tracks=@([ordered]@{ + id='cell-od-watershed-monusac-v1';candidateId='cell-od-watershed-v2' + capability='cell-morphology';scope='deployment';requestPath=[IO.Path]::GetFileName($requestPath) + remediationRequestPath=$null;expectedAttestationPath=[IO.Path]::GetFileName($attestationPath) + protocolSha256=$protocolSha;dependsOn=@();required=$true + }) + }) + [IO.File]::WriteAllLines((Join-Path $campaignRoot 'sample-ledger.jsonl'), @(([ordered]@{ + sampleId=$CohortId;source='Official MoNuSAC 2020 testing archive';patientGroup='23-held-out-patients' + slideGroup='23-annotation-bearing-fields';sha256=$cohortSha;license='CC-BY-NC-SA-4.0' + permittedUse='private-research-restricted';task='cell-instance-qualification';split='qualification-held-out-test' + } | ConvertTo-Json -Compress)), [Text.UTF8Encoding]::new($false)) + $preparationPath = Join-Path $campaignRoot 'preparation.json' + Write-JsonAtomic $preparationPath ([ordered]@{ + schema='pathlab.campaign-preparation/1';campaignManifest='campaign.json' + sampleLedger='sample-ledger.jsonl';artifacts=@() + }) + & (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') ` + -PreparationManifest $preparationPath -StateRoot $state + try { + $response = Invoke-RestMethod -Method Get -Uri "$origin/v1/qualification-runs/$CampaignId" -Headers $headers + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { throw } + $response = Invoke-RestMethod -Method Post -Uri "$origin/v1/qualification-runs" -Headers $headers ` + -ContentType 'application/json' -Body (@{manifestPath=$campaignPath}|ConvertTo-Json -Compress) + } + $control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers +} finally { + if ($control.acceptingJobs -ne $true) { + try { $control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers } catch { } + } + Remove-Variable token,headers -ErrorAction SilentlyContinue +} +[pscustomobject]@{ + CampaignId=$response.id;State=$response.state;campaignCompleted=$response.campaignCompleted + campaignTargetMet=$response.campaignTargetMet;AcceptingJobs=$control.acceptingJobs + SampleCount=23;CohortManifestSha256=$cohortSha;PermittedUse='private-research-restricted' +} diff --git a/scripts/stage-nct-crc-dinov2-execution.ps1 b/scripts/stage-nct-crc-dinov2-execution.ps1 new file mode 100644 index 0000000..8789c04 --- /dev/null +++ b/scripts/stage-nct-crc-dinov2-execution.ps1 @@ -0,0 +1,184 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CampaignId = 'dinov2-nct-crc-gi-execution-20260823-v1' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +$state = [IO.Path]::GetFullPath($StateRoot) +$cohortRoot = Join-Path $state 'derived\he-dinov2-small-v1\nct-crc-gi-20-per-class-v1' +$cohortPath = Join-Path $cohortRoot 'cohort.json' +$modelRoot = Join-Path $state 'models\he-dinov2-small-v1\1' +$packSource = Join-Path $repository 'src\main\resources\evidence-packs\he-dinov2-small-v1.json' +$packTarget = Join-Path $modelRoot 'qualification-pack.json' +$campaignParent = Join-Path $state 'acceptance\campaigns' +$campaignRoot = [IO.Path]::GetFullPath((Join-Path $campaignParent $CampaignId)) +if (-not $campaignRoot.StartsWith([IO.Path]::GetFullPath($campaignParent) + + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw 'GI execution campaign path escaped the acceptance root.' +} + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-Json([string] $Path, [object] $Value) { + [IO.File]::WriteAllText($Path, (($Value | ConvertTo-Json -Depth 16) + "`n"), + [Text.UTF8Encoding]::new($false)) +} + +if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { + throw 'The immutable NCT-CRC GI cohort is unavailable.' +} +$cohort = Get-Content -LiteralPath $cohortPath -Raw | ConvertFrom-Json +if ($cohort.schema -ne 'pathlab.qualification-cohort/1' -or + $cohort.executionStatus -ne 'ready' -or $cohort.qualificationStatus -ne 'not_evaluable' -or + @($cohort.samples).Count -ne 360) { + throw 'The NCT-CRC GI cohort contract is invalid.' +} +$sample = @($cohort.samples | Where-Object { + $_.split -eq 'query' -and $_.phenotypeGroup -eq 'TUM' +} | Sort-Object id | Select-Object -First 1) +if ($sample.Count -ne 1) { throw 'No frozen NCT-CRC query tumor sample is available.' } +$tileManifestPath = [IO.Path]::GetFullPath((Join-Path $cohortRoot ([string]$sample[0].tileCacheManifest))) +if (-not $tileManifestPath.StartsWith([IO.Path]::GetFullPath($cohortRoot) + + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $tileManifestPath -PathType Leaf) -or + (Sha256 $tileManifestPath) -ne [string]$sample[0].tileCacheManifestSha256) { + throw 'The selected GI tile-cache checksum or path is invalid.' +} +$tileManifest = Get-Content -LiteralPath $tileManifestPath -Raw | ConvertFrom-Json +$sampleRoot = Split-Path -Parent $tileManifestPath +$sourcePath = Join-Path $sampleRoot 'source.png' +$sampleManifestPath = Join-Path $sampleRoot ([string]$tileManifest.source.sampleManifest) +$sampleManifest = Get-Content -LiteralPath $sampleManifestPath -Raw | ConvertFrom-Json +if ((Sha256 $sourcePath) -ne [string]$tileManifest.source.sha256 -or + (Sha256 $sampleManifestPath) -ne [string]$tileManifest.source.sampleManifestSha256 -or + [string]$sampleManifest.sha256 -ne [string]$tileManifest.source.sha256 -or + $sampleManifest.permittedUse -ne 'private-research') { + throw 'The selected GI source or provenance checksum is invalid.' +} + +$pack = Get-Content -LiteralPath $packSource -Raw | ConvertFrom-Json +if ($pack.schema -ne 'pathlab.ai-pack/1' -or $pack.packId -ne 'he-dinov2-small-v1' -or + $pack.validation.status -ne 'experimental' -or $pack.rights.allowedUse -ne 'private-research') { + throw 'The DINOv2 qualification pack is not the expected fail-closed experimental candidate.' +} +$artifactFiles = @{ + model='model.safetensors'; config='config.json'; preprocessor='preprocessor_config.json' + worker='worker.exe'; workerSource='worker.py'; 'runtime-manifest'='runtime-manifest.json' +} +foreach ($artifact in $pack.artifacts) { + if (-not $artifactFiles.ContainsKey([string]$artifact.name)) { continue } + $artifactPath = Join-Path $modelRoot $artifactFiles[[string]$artifact.name] + if (-not (Test-Path -LiteralPath $artifactPath -PathType Leaf) -or + (Sha256 $artifactPath) -ne [string]$artifact.sha256) { + throw "The pinned DINOv2 artifact changed: $($artifact.name)" + } +} +Copy-Item -LiteralPath $packSource -Destination "$packTarget.partial" -Force +Move-Item -LiteralPath "$packTarget.partial" -Destination $packTarget -Force + +New-Item -ItemType Directory -Path $campaignRoot -Force | Out-Null +$campaignPath = Join-Path $campaignRoot 'campaign.json' +$requestPath = Join-Path $campaignRoot 'request-he-dinov2-small-v1.json' +$attestationPath = Join-Path $campaignRoot 'attestation-he-dinov2-small-v1.json' +$protocolPath = Join-Path $repository 'docs\evidence\he-retrieval-qualification-protocol-v1.md' +$protocolSha = Sha256 $protocolPath +Write-Json $requestPath ([ordered]@{ + schema = 'pathlab.evidence-job/2' + sourcePath = $sourcePath + sourceSha256 = [string]$tileManifest.source.sha256 + slideRevision = [string]$tileManifest.source.slideRevision + previewPath = $sourcePath + sourceWidth = 512 + sourceHeight = 512 + packManifest = $packTarget + stain = 'he' + marker = 'generic' + tileCacheManifest = $tileManifestPath + tileCacheManifestSha256 = Sha256 $tileManifestPath + qualificationCampaignManifest = $campaignPath +}) +Write-Json $campaignPath ([ordered]@{ + schema = 'pathlab.qualification-campaign/1' + campaignId = $CampaignId + createdAt = '2026-08-23T03:02:46Z' + researchOnly = $true + notDiagnostic = $true + maxRemediationAttempts = 1 + quota = [ordered]@{ + sourceBytes = 45GB; derivedBytes = 25GB; modelBytes = 10GB + evidenceBytes = 10GB; reserveBytes = 10GB + } + tracks = @([ordered]@{ + id = 'he-dinov2-small-gi-execution-v1' + candidateId = 'he-dinov2-small-v1' + capability = 'he-evidence' + scope = 'deployment' + requestPath = [IO.Path]::GetFileName($requestPath) + remediationRequestPath = $null + expectedAttestationPath = [IO.Path]::GetFileName($attestationPath) + protocolSha256 = $protocolSha + dependsOn = @() + required = $true + }) +}) +$ledgerPath = Join-Path $campaignRoot 'sample-ledger.jsonl' +$ledger = [ordered]@{ + sampleId = [string]$sample[0].id + source = [string]$sampleManifest.source + patientGroup = [string]$sampleManifest.patientGroup + slideGroup = [string]$sampleManifest.slideId + sha256 = [string]$sample[0].archiveEntrySha256 + license = [string]$sampleManifest.license + permittedUse = [string]$sampleManifest.permittedUse + task = 'real-gi-execution-smoke-no-retrieval-claim' + split = [string]$sample[0].split +} +[IO.File]::WriteAllText($ledgerPath, (($ledger | ConvertTo-Json -Compress) + "`n"), + [Text.UTF8Encoding]::new($false)) +$preparationPath = Join-Path $campaignRoot 'preparation.json' +Write-Json $preparationPath ([ordered]@{ + schema = 'pathlab.campaign-preparation/1' + campaignManifest = 'campaign.json' + sampleLedger = 'sample-ledger.jsonl' + artifacts = @() +}) +& (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') ` + -PreparationManifest $preparationPath -StateRoot $state + +$endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json +if ($endpoint.serviceVersion -notin @('2.1.1', '2.1.2')) { + throw "Service 2.1.1 or 2.1.2 is required; found $($endpoint.serviceVersion)." +} +$token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +$headers = @{ Authorization = "Bearer $token" } +try { + try { + $response = Invoke-RestMethod -Method Get ` + -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs/$CampaignId" ` + -Headers $headers + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { throw } + $response = Invoke-RestMethod -Method Post ` + -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs" ` + -Headers $headers -ContentType 'application/json' ` + -Body (@{ manifestPath = $campaignPath } | ConvertTo-Json -Compress) + } + $control = Invoke-RestMethod -Method Post ` + -Uri "http://127.0.0.1:$($endpoint.port)/v1/control/resume" -Headers $headers +} finally { + Remove-Variable token, headers -ErrorAction SilentlyContinue +} +[pscustomobject]@{ + CampaignId = $response.id + State = $response.state + CampaignCompleted = $response.campaignCompleted + CampaignTargetMet = $response.campaignTargetMet + AcceptingJobs = $control.acceptingJobs + ManifestPath = $campaignPath + ManifestSha256 = Sha256 $campaignPath + CohortManifestSha256 = Sha256 $cohortPath +} diff --git a/scripts/stage-nct-crc-dinov2-retrieval.ps1 b/scripts/stage-nct-crc-dinov2-retrieval.ps1 new file mode 100644 index 0000000..dc5163a --- /dev/null +++ b/scripts/stage-nct-crc-dinov2-retrieval.ps1 @@ -0,0 +1,161 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CampaignId = 'dinov2-nct-crc-gi-retrieval-20260823-v3', + [string] $CohortManifestSha256 = 'e3fb44a4e977aeb99a1a5d0a6a97fcd8495fc14b24e66ac126e8205594bdbb38' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +$state = [IO.Path]::GetFullPath($StateRoot) +$cohortRoot = Join-Path $state 'derived\he-dinov2-small-v1\nct-crc-gi-20-per-class-v1' +$cohortPath = Join-Path $cohortRoot 'cohort.json' +$modelRoot = Join-Path $state 'models\he-dinov2-small-v1\1' +$workerSource = Join-Path $repository 'src\main\resources\model-workers\dinov2-worker.py' +$workerTarget = Join-Path $modelRoot 'worker.py' +$packSource = Join-Path $repository 'src\main\resources\evidence-packs\he-dinov2-small-v1.json' +$packTarget = Join-Path $modelRoot 'qualification-pack.json' +$campaignParent = Join-Path $state 'acceptance\campaigns' +$campaignRoot = [IO.Path]::GetFullPath((Join-Path $campaignParent $CampaignId)) + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path -Force +} + +if (-not $campaignRoot.StartsWith([IO.Path]::GetFullPath($campaignParent) + + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw 'GI retrieval campaign path escaped the acceptance root.' +} +foreach ($required in @($workerSource, $workerTarget, $packSource, + (Join-Path $modelRoot 'worker.exe'), (Join-Path $modelRoot 'model.safetensors'), + (Join-Path $modelRoot 'runtime-manifest.json'))) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required input is unavailable: $required" } +} +if ($CohortManifestSha256 -notmatch '^[a-f0-9]{64}$') { throw 'The frozen cohort checksum is invalid.' } +$cohortSha = $CohortManifestSha256 +$cohort = $null +try { + $cohort = Get-Content -LiteralPath $cohortPath -Raw | ConvertFrom-Json + if ($cohort.schema -ne 'pathlab.qualification-cohort/1' -or $cohort.executionStatus -ne 'ready' -or + @($cohort.samples).Count -ne 360 -or (Sha256 $cohortPath) -ne $cohortSha) { + throw 'The frozen NCT-CRC cohort contract is invalid.' + } +} catch [System.UnauthorizedAccessException] { + # The installed ACL deliberately delegates derived-data validation to LocalService. +} +$pack = Get-Content -LiteralPath $packSource -Raw | ConvertFrom-Json +if ($pack.packId -ne 'he-dinov2-small-v1' -or $pack.version -ne '1' -or + $pack.runtimeCompatibility.workerProtocol -ne 'pathlab.model-worker/2' -or + $pack.resourceEnvelope.maxSeconds -ne 1200) { throw 'The cohort worker pack contract is invalid.' } +$artifactFiles = @{ + model='model.safetensors'; config='config.json'; preprocessor='preprocessor_config.json' + worker='worker.exe'; workerSource='worker.py'; 'runtime-manifest'='runtime-manifest.json' +} +foreach ($artifact in $pack.artifacts) { + if (-not $artifactFiles.ContainsKey([string]$artifact.name) -or $artifact.name -eq 'workerSource') { continue } + $path = Join-Path $modelRoot $artifactFiles[[string]$artifact.name] + if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or (Sha256 $path) -ne [string]$artifact.sha256) { + throw "Pinned model artifact changed: $($artifact.name)" + } +} +if ((Sha256 $workerSource) -ne [string](@($pack.artifacts | Where-Object name -eq 'workerSource')[0].sha256)) { + throw 'Repository worker source does not match the pack ledger.' +} + +$endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json +$token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +$headers = @{ Authorization = "Bearer $token" } +$origin = "http://127.0.0.1:$($endpoint.port)" +$control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/pause" -Headers $headers +$status = Invoke-RestMethod -Method Get -Uri "$origin/v1/status" -Headers $headers +if ($status.queue.active -ne 0) { throw 'A worker is active; cohort worker installation stopped safely.' } + +$oldWorkerSha = Sha256 $workerTarget +$archiveRoot = Join-Path $modelRoot 'worker-source-archive' +New-Item -ItemType Directory -Path $archiveRoot -Force | Out-Null +$archivePath = Join-Path $archiveRoot "$oldWorkerSha.py" +if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { + Copy-Item -LiteralPath $workerTarget -Destination "$archivePath.partial" + Move-Item -LiteralPath "$archivePath.partial" -Destination $archivePath +} +if ((Sha256 $workerTarget) -ne (Sha256 $workerSource)) { + Copy-Item -LiteralPath $workerSource -Destination "$workerTarget.partial" -Force + Move-Item -LiteralPath "$workerTarget.partial" -Destination $workerTarget -Force +} +Copy-Item -LiteralPath $packSource -Destination "$packTarget.partial" -Force +Move-Item -LiteralPath "$packTarget.partial" -Destination $packTarget -Force + +New-Item -ItemType Directory -Path $campaignRoot -Force | Out-Null +$campaignPath = Join-Path $campaignRoot 'campaign.json' +$requestPath = Join-Path $campaignRoot 'request-he-dinov2-small-v1.json' +$attestationPath = Join-Path $campaignRoot 'attestation-he-dinov2-small-v1.json' +$priorRequestPath = Join-Path $campaignParent ` + 'dinov2-nct-crc-gi-execution-20260823-v1\request-he-dinov2-small-v1.json' +$priorRequest = Get-Content -LiteralPath $priorRequestPath -Raw | ConvertFrom-Json +$tileManifestPath = [string]$priorRequest.tileCacheManifest +$sourcePath = [string]$priorRequest.sourcePath +$protocolPath = Join-Path $repository 'docs\evidence\he-retrieval-qualification-protocol-v1.md' +$protocolSha = Sha256 $protocolPath + +Write-JsonAtomic $requestPath ([ordered]@{ + schema = 'pathlab.evidence-job/2'; sourcePath = $sourcePath + sourceSha256 = [string]$priorRequest.sourceSha256 + slideRevision = [string]$priorRequest.slideRevision; previewPath = $sourcePath + sourceWidth = 512; sourceHeight = 512; packManifest = $packTarget; stain = 'he' + marker = "cohort-$cohortSha"; tileCacheManifest = $tileManifestPath + tileCacheManifestSha256 = [string]$priorRequest.tileCacheManifestSha256 + qualificationCampaignManifest = $campaignPath +}) +Write-JsonAtomic $campaignPath ([ordered]@{ + schema = 'pathlab.qualification-campaign/1'; campaignId = $CampaignId + createdAt = [DateTimeOffset]::UtcNow.ToString('o'); researchOnly = $true; notDiagnostic = $true + maxRemediationAttempts = 1 + quota = [ordered]@{ sourceBytes=45GB; derivedBytes=25GB; modelBytes=10GB; evidenceBytes=10GB; reserveBytes=10GB } + tracks = @([ordered]@{ + id='he-dinov2-small-gi-retrieval-v1'; candidateId='he-dinov2-small-v1' + capability='he-evidence'; scope='deployment'; requestPath=[IO.Path]::GetFileName($requestPath) + remediationRequestPath=$null; expectedAttestationPath=[IO.Path]::GetFileName($attestationPath) + protocolSha256=$protocolSha; dependsOn=@(); required=$true + }) +}) +$ledgerPath = Join-Path $campaignRoot 'sample-ledger.jsonl' +$ledgerLines = @(([ordered]@{ + sampleId='nct-crc-gi-20-per-class-v1'; source='Zenodo record 1214456 frozen cohort manifest' + patientGroup='not-provided-by-public-patch-release'; slideGroup='immutable-360-patch-cohort' + sha256=$cohortSha; license='CC-BY-4.0'; permittedUse='private-research' + task='he-retrieval-qualification'; split='reference-and-query' +} | ConvertTo-Json -Compress)) +[IO.File]::WriteAllLines($ledgerPath, $ledgerLines, [Text.UTF8Encoding]::new($false)) +$preparationPath = Join-Path $campaignRoot 'preparation.json' +Write-JsonAtomic $preparationPath ([ordered]@{ + schema='pathlab.campaign-preparation/1'; campaignManifest='campaign.json' + sampleLedger='sample-ledger.jsonl'; artifacts=@() +}) +& (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') ` + -PreparationManifest $preparationPath -StateRoot $state + +try { + try { + $response = Invoke-RestMethod -Method Get -Uri "$origin/v1/qualification-runs/$CampaignId" -Headers $headers + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { throw } + $response = Invoke-RestMethod -Method Post -Uri "$origin/v1/qualification-runs" -Headers $headers ` + -ContentType 'application/json' -Body (@{manifestPath=$campaignPath} | ConvertTo-Json -Compress) + } + $control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers +} finally { + Remove-Variable token, headers -ErrorAction SilentlyContinue +} +[pscustomobject]@{ + CampaignId=$response.id; State=$response.state; CampaignCompleted=$response.campaignCompleted + CampaignTargetMet=$response.campaignTargetMet; AcceptingJobs=$control.acceptingJobs + SampleCount=360; CohortManifestSha256=$cohortSha + WorkerSourceSha256=Sha256 $workerTarget; PackManifestSha256=Sha256 $packTarget +} diff --git a/scripts/stage-tcga-lung-dinov2-retrieval.ps1 b/scripts/stage-tcga-lung-dinov2-retrieval.ps1 new file mode 100644 index 0000000..7e1bec2 --- /dev/null +++ b/scripts/stage-tcga-lung-dinov2-retrieval.ps1 @@ -0,0 +1,157 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $CampaignId = 'dinov2-tcga-lung-retrieval-20260823-v1', + [string] $CohortId = 'tcga-luad-lusc-lung-20x2-v1', + [string] $TrackId = 'he-dinov2-small-tcga-lung-v1', + [string] $ProtocolPath = 'docs\evidence\he-retrieval-qualification-protocol-v1.md' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = Split-Path -Parent $PSScriptRoot +$state = [IO.Path]::GetFullPath($StateRoot) +$cohortRoot = Join-Path $state "derived\qualification-prepared\$CohortId" +$cohortPath = Join-Path $cohortRoot 'cohort.json' +$modelRoot = Join-Path $state 'models\he-dinov2-small-v1\1' +$packSource = Join-Path $repository 'src\main\resources\evidence-packs\he-dinov2-small-v1.json' +$workerSource = Join-Path $repository 'src\main\resources\model-workers\dinov2-worker.py' +$packTarget = Join-Path $modelRoot 'qualification-pack.json' +$workerTarget = Join-Path $modelRoot 'worker.py' +$campaignRoot = Join-Path $state "acceptance\campaigns\$CampaignId" + +function Sha256([string] $Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Write-JsonAtomic([string] $Path, [object] $Value) { + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, (($Value | ConvertTo-Json -Depth 20) + "`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path -Force +} + +foreach ($required in @($packSource,$workerSource,$workerTarget, + (Join-Path $modelRoot 'worker.exe'),(Join-Path $modelRoot 'model.safetensors'), + (Join-Path $modelRoot 'runtime-manifest.json'))) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required input is unavailable: $required" } +} +$derivedRoot = Join-Path $state 'derived' +$derivedAcl = Get-Acl -LiteralPath $derivedRoot +$derivedSddl = $derivedAcl.Sddl +try { + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $temporaryRead = New-Object Security.AccessControl.FileSystemAccessRule( + $currentIdentity, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $derivedAcl.AddAccessRule($temporaryRead) | Out-Null + Set-Acl -LiteralPath $derivedRoot -AclObject $derivedAcl + if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { throw 'The TCGA cohort is unavailable.' } + $cohortSha = Sha256 $cohortPath + $cohort = Get-Content -LiteralPath $cohortPath -Raw | ConvertFrom-Json + if ($cohort.schema -ne 'pathlab.qualification-cohort/1' -or @($cohort.samples).Count -ne 40 -or + @($cohort.samples | Where-Object split -eq 'reference').Count -ne 20 -or + @($cohort.samples | Where-Object split -eq 'query').Count -ne 20) { + throw 'The frozen TCGA lung cohort contract is invalid.' + } + $first = $cohort.samples[0] + $tileManifestPath = Join-Path $cohortRoot ([string]$first.tileCacheManifest) + $tileManifest = Get-Content -LiteralPath $tileManifestPath -Raw | ConvertFrom-Json + $sourcePath = Join-Path (Split-Path -Parent $tileManifestPath) ([string]$tileManifest.tiles[0].path) + if ((Sha256 $tileManifestPath) -ne [string]$first.tileCacheManifestSha256 -or + (Sha256 $sourcePath) -ne [string]$tileManifest.tiles[0].sha256) { + throw 'The TCGA cohort seed tile changed.' + } +} finally { + $restoredAcl = New-Object Security.AccessControl.DirectorySecurity + $restoredAcl.SetSecurityDescriptorSddlForm($derivedSddl) + Set-Acl -LiteralPath $derivedRoot -AclObject $restoredAcl +} + +$endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json +if ([version]$endpoint.serviceVersion -lt [version]'2.1.4') { + throw 'Service 2.1.4 is required for strict signed aggregate qualification reports.' +} +$token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() +$headers = @{Authorization="Bearer $token"} +$origin = "http://127.0.0.1:$($endpoint.port)" +$control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/pause" -Headers $headers +$status = Invoke-RestMethod -Method Get -Uri "$origin/v1/status" -Headers $headers +if ($status.queue.active -ne 0) { throw 'A worker is active; model-pack refresh stopped safely.' } + +$pack = Get-Content -LiteralPath $packSource -Raw | ConvertFrom-Json +if ($pack.packId -ne 'he-dinov2-small-v1' -or $pack.runtimeCompatibility.workerProtocol -ne 'pathlab.model-worker/2') { + throw 'The DINOv2 pack contract is invalid.' +} +if ((Sha256 $workerSource) -ne [string](@($pack.artifacts | Where-Object name -eq 'workerSource')[0].sha256)) { + throw 'Repository worker source does not match the signed pack ledger.' +} +$archiveRoot = Join-Path $modelRoot 'worker-source-archive' +New-Item -ItemType Directory -Path $archiveRoot -Force | Out-Null +$oldWorkerSha = Sha256 $workerTarget +$archivePath = Join-Path $archiveRoot "$oldWorkerSha.py" +if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { + Copy-Item -LiteralPath $workerTarget -Destination "$archivePath.partial" + Move-Item -LiteralPath "$archivePath.partial" -Destination $archivePath +} +Copy-Item -LiteralPath $workerSource -Destination "$workerTarget.partial" -Force +Move-Item -LiteralPath "$workerTarget.partial" -Destination $workerTarget -Force +Copy-Item -LiteralPath $packSource -Destination "$packTarget.partial" -Force +Move-Item -LiteralPath "$packTarget.partial" -Destination $packTarget -Force + +New-Item -ItemType Directory -Path $campaignRoot -Force | Out-Null +$campaignPath = Join-Path $campaignRoot 'campaign.json' +$requestPath = Join-Path $campaignRoot 'request-he-dinov2-small-v1.json' +$attestationPath = Join-Path $campaignRoot 'attestation-he-dinov2-small-v1.json' +$protocolPath = if ([IO.Path]::IsPathRooted($ProtocolPath)) { $ProtocolPath } else { Join-Path $repository $ProtocolPath } +if (-not (Test-Path -LiteralPath $protocolPath -PathType Leaf)) { throw 'Qualification protocol is unavailable.' } +$protocolSha = Sha256 $protocolPath +Write-JsonAtomic $requestPath ([ordered]@{ + schema='pathlab.evidence-job/2'; sourcePath=$sourcePath + sourceSha256=[string]$tileManifest.source.sha256 + slideRevision=[string]$tileManifest.source.slideRevision; previewPath=$sourcePath + sourceWidth=[int]$tileManifest.source.width; sourceHeight=[int]$tileManifest.source.height + packManifest=$packTarget; stain='he'; marker="cohort-$cohortSha" + tileCacheManifest=$tileManifestPath; tileCacheManifestSha256=[string]$first.tileCacheManifestSha256 + qualificationCampaignManifest=$campaignPath + qualificationCohortManifest = $cohortPath + qualificationCohortManifestSha256 = $cohortSha +}) +Write-JsonAtomic $campaignPath ([ordered]@{ + schema='pathlab.qualification-campaign/1'; campaignId=$CampaignId + createdAt=[DateTimeOffset]::UtcNow.ToString('o'); researchOnly=$true; notDiagnostic=$true + maxRemediationAttempts=1 + quota=[ordered]@{sourceBytes=45GB;derivedBytes=25GB;modelBytes=10GB;evidenceBytes=10GB;reserveBytes=10GB} + tracks=@([ordered]@{ + id=$TrackId;candidateId='he-dinov2-small-v1' + capability='he-evidence';scope='deployment';requestPath=[IO.Path]::GetFileName($requestPath) + remediationRequestPath=$null;expectedAttestationPath=[IO.Path]::GetFileName($attestationPath) + protocolSha256=$protocolSha;dependsOn=@();required=$true + }) +}) +[IO.File]::WriteAllLines((Join-Path $campaignRoot 'sample-ledger.jsonl'), @(([ordered]@{ + sampleId=$CohortId;source='NCI GDC frozen open-access lung cohort' + patientGroup='forty-patient-disjoint';slideGroup='forty-checksum-bound-slides';sha256=$cohortSha + license='NIH-GDS/NCI-GDC-open-access-policy';permittedUse='private-research' + task='he-retrieval-qualification';split='reference-and-query' +} | ConvertTo-Json -Compress)), [Text.UTF8Encoding]::new($false)) +$preparationPath = Join-Path $campaignRoot 'preparation.json' +Write-JsonAtomic $preparationPath ([ordered]@{ + schema='pathlab.campaign-preparation/1';campaignManifest='campaign.json' + sampleLedger='sample-ledger.jsonl';artifacts=@() +}) +& (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') ` + -PreparationManifest $preparationPath -StateRoot $state +try { + try { + $response = Invoke-RestMethod -Method Get -Uri "$origin/v1/qualification-runs/$CampaignId" -Headers $headers + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { throw } + $response = Invoke-RestMethod -Method Post -Uri "$origin/v1/qualification-runs" -Headers $headers ` + -ContentType 'application/json' -Body (@{manifestPath=$campaignPath}|ConvertTo-Json -Compress) + } + $control = Invoke-RestMethod -Method Post -Uri "$origin/v1/control/resume" -Headers $headers +} finally { Remove-Variable token,headers -ErrorAction SilentlyContinue } +[pscustomobject]@{ + CampaignId=$response.id;State=$response.state;campaignCompleted=$response.campaignCompleted + campaignTargetMet=$response.campaignTargetMet;AcceptingJobs=$control.acceptingJobs + SampleCount=40;CohortManifestSha256=$cohortSha +} diff --git a/scripts/stage-tumorquantai-ihc-fixture-campaign.ps1 b/scripts/stage-tumorquantai-ihc-fixture-campaign.ps1 new file mode 100644 index 0000000..5b7203a --- /dev/null +++ b/scripts/stage-tumorquantai-ihc-fixture-campaign.ps1 @@ -0,0 +1,98 @@ +[CmdletBinding()] +param( + [ValidateSet('Start','Run','Status')] [string] $Action = 'Start', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $RepositoryRoot = '' +) + +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$datasetId='tumorquantai-breast-ihc-4case-v1' +$fixtureDatasetId='tumorquantai-breast-ihc-4case-fixtures-v2' +$campaignId='tumorquantai-ihc-descriptive-execution-20260823-v2' +$taskName='PathLabTumorQuantIhcFixtureCampaign' +$state=[IO.Path]::GetFullPath($StateRoot) +$repository=if([string]::IsNullOrWhiteSpace($RepositoryRoot)){Split-Path -Parent $PSScriptRoot}else{[IO.Path]::GetFullPath($RepositoryRoot)} +$root=Join-Path $state "acquisition\$campaignId" +$statusPath=Join-Path $root 'status.json' +$acquisitionStatusPath=Join-Path $state "acquisition\$datasetId\status.json" +$sourceRoot=Join-Path $state "sources\$datasetId" +$outputRoot=Join-Path $state "derived\qualification-prepared\$fixtureDatasetId" +$campaignRoot=Join-Path $state "acceptance\campaigns\$campaignId" + +function Sha256([string]$Path){(Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()} +function Write-JsonAtomic([string]$Path,[object]$Value){New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force|Out-Null;[IO.File]::WriteAllText("$Path.partial",(($Value|ConvertTo-Json -Depth 20)+"`n"),[Text.UTF8Encoding]::new($false));Move-Item "$Path.partial" $Path -Force} +function Write-Status([string]$StateValue,[long]$Completed,[string]$Detail){Write-JsonAtomic $statusPath ([ordered]@{schema='pathlab.acquisition-status/1';datasetId=$campaignId;state=$StateValue;completedBytes=$Completed;totalBytes=1;detail=$Detail;networkContext='interactive-user-acquisition-only';analysisNetwork='disabled';updatedAt=[DateTimeOffset]::UtcNow.ToString('o')})} +function Finish-Task{Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue} + +if($Action-eq'Status'){if(Test-Path $statusPath){Get-Content $statusPath -Raw}else{'{"schema":"pathlab.acquisition-status/1","state":"not_started"}'};return} +New-Item -ItemType Directory -Path $root -Force|Out-Null +if($Action-eq'Start'){ + $durable=Join-Path $root 'stage-tumorquantai-ihc-fixture-campaign.ps1';Copy-Item $PSCommandPath $durable -Force + $taskAction=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$durable`" -Action Run -StateRoot `"$state`" -RepositoryRoot `"$repository`"" + $trigger=New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 7) + $principal=New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited + $settings=New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 2) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Principal $principal -Settings $settings -Description 'Build and submit bounded TumorQuantAI IHC fixtures after acquisition.' -Force|Out-Null + Write-Status 'validating' 0 'Waiting for the checksum-verified IHC source subset.';Start-ScheduledTask $taskName;Get-Content $statusPath -Raw;return +} + +try{ + if(-not(Test-Path $acquisitionStatusPath)){Write-Status 'validating' 0 'Waiting for IHC acquisition status.';return} + $acquisition=Get-Content $acquisitionStatusPath -Raw|ConvertFrom-Json + if($acquisition.state-ne'completed'){Write-Status 'validating' 0 "Waiting for IHC acquisition: $($acquisition.state).";return} + Write-Status 'validating' 0 'Building checksum-bound marker fixtures.' + $endpoint=Get-Content (Join-Path $state 'endpoint.json') -Raw|ConvertFrom-Json + $token=[IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim();$headers=@{Authorization="Bearer $token"} + Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($endpoint.port)/v1/control/pause" -Headers $headers|Out-Null + $samples=[Collections.Generic.List[object]]::new() + try{ + $runner=Invoke-RestMethod -Headers $headers -Uri "http://127.0.0.1:$($endpoint.port)/v1/status" + if([int]$runner.queue.active-ne 0){throw 'Runner still has active jobs.'} + if([long]$runner.quota.derived.usedBytes+3GB-gt[long]$runner.quota.derived.limitBytes){throw 'Derived quota cannot reserve bounded IHC fixtures.'} + $derivedRoot=Join-Path $state 'derived';$acl=Get-Acl $derivedRoot;$sddl=$acl.Sddl;$identity=[Security.Principal.WindowsIdentity]::GetCurrent().Name + try{ + $rule=New-Object Security.AccessControl.FileSystemAccessRule($identity,'Modify','ContainerInherit,ObjectInherit','None','Allow');$acl.AddAccessRule($rule)|Out-Null;Set-Acl $derivedRoot $acl + if(-not(Test-Path $outputRoot)){ + $partial="$outputRoot.partial";Remove-Item $partial -Recurse -Force -ErrorAction SilentlyContinue;New-Item -ItemType Directory -Path $partial -Force|Out-Null + $ledger=@(Get-Content (Join-Path $sourceRoot 'sample-ledger.jsonl')|ForEach-Object{$_|ConvertFrom-Json}) + if($ledger.Count-ne 4-or@($ledger.patientGroup|Sort-Object -Unique).Count-ne 4){throw 'IHC source ledger is not four-case-disjoint.'} + $patches=Import-Csv (Join-Path $sourceRoot 'patch_manifest.csv');Add-Type -AssemblyName System.IO.Compression.FileSystem;Add-Type -AssemblyName System.Drawing + $markers=@('ER','PR','Ki-67','HER2');$caseIndex=0 + foreach($sourceRecord in $ledger){ + $caseIndex++;$archivePath=Join-Path $sourceRoot "$($sourceRecord.sampleId).zip" + if(-not(Test-Path $archivePath)-or(Sha256 $archivePath)-ne$sourceRecord.sha256){throw "IHC archive checksum changed: $($sourceRecord.sampleId)"} + $archive=[IO.Compression.ZipFile]::OpenRead($archivePath) + try{foreach($marker in $markers){ + $patch=@($patches|Where-Object{$_.case_alias-eq$sourceRecord.sampleId-and$_.marker-eq$marker}|Sort-Object patch_alias|Select-Object -First 1) + if($patch.Count-ne 1-or$patch[0].sha256-notmatch'^[a-f0-9]{64}$'){throw "IHC patch manifest is incomplete: $($sourceRecord.sampleId)/$marker"} + $entry=@($archive.Entries|Where-Object FullName -eq $patch[0].public_path) + if($entry.Count-ne 1-or[long]$entry[0].Length-ne[long]$patch[0].size_bytes){throw "IHC archive member changed: $($patch[0].patch_alias)"} + $sampleId="$($sourceRecord.sampleId)-$($marker.ToLowerInvariant().Replace('-',''))";$sampleRoot=Join-Path $partial $sampleId;New-Item -ItemType Directory -Path $sampleRoot -Force|Out-Null + $tiff=Join-Path $sampleRoot 'source.tif';[IO.Compression.ZipFileExtensions]::ExtractToFile($entry[0],$tiff,$true) + if((Sha256 $tiff)-ne$patch[0].sha256){throw "IHC patch checksum changed: $($patch[0].patch_alias)"} + $bitmap=[Drawing.Bitmap]::new($tiff);try{$side=[Math]::Min($bitmap.Width,$bitmap.Height);$x=[int](($bitmap.Width-$side)/2);$y=[int](($bitmap.Height-$side)/2);$preview=[Drawing.Bitmap]::new(512,512);try{$graphics=[Drawing.Graphics]::FromImage($preview);try{$graphics.InterpolationMode=[Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic;$graphics.DrawImage($bitmap,[Drawing.Rectangle]::new(0,0,512,512),[Drawing.Rectangle]::new($x,$y,$side,$side),[Drawing.GraphicsUnit]::Pixel)}finally{$graphics.Dispose()};$png=Join-Path $sampleRoot 'preview.png';$preview.Save("$png.partial",[Drawing.Imaging.ImageFormat]::Png);Move-Item "$png.partial" $png}finally{$preview.Dispose()}}finally{$bitmap.Dispose();Remove-Item $tiff -Force} + $samples.Add([ordered]@{id=$sampleId;caseId=[string]$sourceRecord.sampleId;marker=$marker.ToLowerInvariant();split=if($caseIndex-le 2){'reference'}else{'query'};relativePath="$sampleId/preview.png";sha256=Sha256 $png;sourceArchiveSha256=[string]$sourceRecord.sha256;sourcePatchSha256=[string]$patch[0].sha256;license='CC-BY-4.0';permittedUse='private-research-descriptive-only';crossSectionCellCorrespondence=$false}) + }}finally{$archive.Dispose()} + } + Write-JsonAtomic (Join-Path $partial 'fixture-manifest.json') ([ordered]@{schema='pathlab.ihc-fixture-set/1';datasetId=$fixtureDatasetId;researchOnly=$true;notDiagnostic=$true;samples=$samples.ToArray()}) + Move-Item $partial $outputRoot + }else{$fixture=Get-Content (Join-Path $outputRoot 'fixture-manifest.json') -Raw|ConvertFrom-Json;$fixture.samples|ForEach-Object{$samples.Add($_)}} + $fixtureSha=Sha256 (Join-Path $outputRoot 'fixture-manifest.json') + foreach($sample in $samples){$finalPath=[IO.Path]::GetFullPath((Join-Path $outputRoot $sample.relativePath));if(-not$finalPath.StartsWith($outputRoot+[IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)-or-not(Test-Path $finalPath)-or(Sha256 $finalPath)-ne$sample.sha256){throw "Final IHC fixture is unavailable: $($sample.marker)"}} + }finally{$restored=New-Object Security.AccessControl.DirectorySecurity;$restored.SetSecurityDescriptorSddlForm($sddl);Set-Acl $derivedRoot $restored} + }finally{Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($endpoint.port)/v1/control/resume" -Headers $headers|Out-Null} + + New-Item -ItemType Directory -Path $campaignRoot -Force|Out-Null + $packSource=Join-Path $repository 'src\main\resources\evidence-packs\ihc-descriptive-v2.json';$packTarget=Join-Path $campaignRoot 'ihc-descriptive-v2.json';Copy-Item $packSource $packTarget -Force + $protocol=Join-Path $repository 'qualification-protocols\ihc-descriptive-v2.json';$protocolSha=Sha256 $protocol + if($protocolSha-ne'9b2d3299eed5504fe7d89839cacae6ea5232762d6405bdfab261ad78e30b991f'){throw 'Frozen IHC protocol checksum changed.'} + $campaignPath=Join-Path $campaignRoot 'campaign.json';$tracks=[Collections.Generic.List[object]]::new();$ledgerLines=[Collections.Generic.List[string]]::new() + foreach($sample in $samples){$ledgerLines.Add(([ordered]@{sampleId=$sample.id;source="Zenodo-21797920/$($sample.caseId)";patientGroup=$sample.caseId;slideGroup=$sample.id;sha256=$sample.sha256;license='CC-BY-4.0';permittedUse='private-research-descriptive-only';task='ihc-descriptive-integration';split=$sample.split}|ConvertTo-Json -Compress))} + foreach($marker in @('er','pr','ki-67','her2')){$sample=@($samples|Where-Object{$_.marker-eq$marker-and$_.split-eq'query'}|Select-Object -First 1)[0];$sourcePath=[IO.Path]::GetFullPath((Join-Path $outputRoot $sample.relativePath));$requestName="request-$($marker.Replace('-','')).json";Write-JsonAtomic (Join-Path $campaignRoot $requestName) ([ordered]@{schema='pathlab.evidence-job/2';sourcePath=$sourcePath;sourceSha256=$sample.sha256;slideRevision="tumorquantai:$($sample.caseId):$($sample.sourcePatchSha256)";previewPath=$sourcePath;sourceWidth=512;sourceHeight=512;packManifest=$packTarget;stain='ihc_dab';marker=$marker;markerIdentitySource='import-metadata';controlsValidated=$false;qualificationCampaignManifest=$campaignPath});$tracks.Add([ordered]@{id="ihc-$($marker.Replace('-',''))-execution-v2";candidateId='ihc-descriptive-v2';capability='ihc-descriptive';scope='deployment';requestPath=$requestName;remediationRequestPath=$null;expectedAttestationPath="attestation-$($marker.Replace('-','')).json";protocolSha256=$protocolSha;dependsOn=@();required=$true})} + Write-JsonAtomic $campaignPath ([ordered]@{schema='pathlab.qualification-campaign/1';campaignId=$campaignId;createdAt=[DateTimeOffset]::UtcNow.ToString('o');researchOnly=$true;notDiagnostic=$true;maxRemediationAttempts=1;quota=[ordered]@{sourceBytes=45GB;derivedBytes=25GB;modelBytes=10GB;evidenceBytes=10GB;reserveBytes=10GB};tracks=$tracks.ToArray()}) + [IO.File]::WriteAllLines((Join-Path $campaignRoot 'sample-ledger.jsonl'),$ledgerLines,[Text.UTF8Encoding]::new($false));Write-JsonAtomic (Join-Path $campaignRoot 'preparation.json') ([ordered]@{schema='pathlab.campaign-preparation/1';campaignManifest='campaign.json';sampleLedger='sample-ledger.jsonl';artifacts=@()}) + & (Join-Path $repository 'scripts\prepare-all-rounder-campaign.ps1') -PreparationManifest (Join-Path $campaignRoot 'preparation.json') -StateRoot $state + try{$response=Invoke-RestMethod -Headers $headers -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs/$campaignId"}catch{if($_.Exception.Response.StatusCode-ne 404){throw};$response=Invoke-RestMethod -Method Post -Headers $headers -ContentType 'application/json' -Body (@{manifestPath=$campaignPath}|ConvertTo-Json -Compress) -Uri "http://127.0.0.1:$($endpoint.port)/v1/qualification-runs"} + Write-Status 'completed' 1 "Fixture SHA256 $fixtureSha; campaign state $($response.state).";Finish-Task +}catch{Write-Status 'failed' 0 $_.Exception.Message;Finish-Task;throw}finally{Remove-Variable token,headers -ErrorAction SilentlyContinue} diff --git a/scripts/start-tcga-lung-cohort-build.ps1 b/scripts/start-tcga-lung-cohort-build.ps1 new file mode 100644 index 0000000..1878596 --- /dev/null +++ b/scripts/start-tcga-lung-cohort-build.ps1 @@ -0,0 +1,122 @@ +[CmdletBinding()] +param( + [ValidateSet('Start','Run','Status')] [string] $Action = 'Start', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [ValidateSet('v1','qc-remediation-v1')] [string] $SelectionProtocol = 'v1', + [string] $FrozenAt = '2026-08-23T06:50:00Z' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$taskName = if ($SelectionProtocol -eq 'qc-remediation-v1') { + 'PathLabTcgaLungCohortRemediation' +} else { 'PathLabTcgaLungCohortBuild' } +$state = [IO.Path]::GetFullPath($StateRoot) +$cohortId = if ($SelectionProtocol -eq 'qc-remediation-v1') { + 'tcga-luad-lusc-lung-20x2-qc-remediation-v1' +} else { 'tcga-luad-lusc-lung-20x2-v1' } +$buildFolder = if ($SelectionProtocol -eq 'v1') { 'cohort-build' } else { 'cohort-build-qc-remediation-v1' } +$root = Join-Path $state "acquisition\tcga-luad-lusc-he-20x2-v1\$buildFolder" +$statusPath = Join-Path $root 'status.json' +$acquisitionStatusPath = Join-Path $state 'acquisition\tcga-luad-lusc-he-20x2-v1\status.json' +$cohortPath = Join-Path $state "derived\qualification-prepared\$cohortId\cohort.json" + +function Write-Status([string] $StateValue, [string] $Detail) { + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + New-Item -ItemType Directory -Path $root -Force | Out-Null + } + $value = [ordered]@{ + schema='pathlab.cohort-build-status/1';datasetId='tcga-luad-lusc-he-20x2-v1' + state=$StateValue;detail=$Detail;updatedAt=[DateTimeOffset]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText("$statusPath.partial", (($value|ConvertTo-Json -Depth 6)+"`n"), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$statusPath.partial" -Destination $statusPath -Force +} + +if ($Action -eq 'Status') { + if (Test-Path -LiteralPath $statusPath -PathType Leaf) { Get-Content -LiteralPath $statusPath -Raw } + else { '{"schema":"pathlab.cohort-build-status/1","state":"not_started"}' } + return +} +if (-not (Test-Path -LiteralPath $root -PathType Container)) { + New-Item -ItemType Directory -Path $root -Force | Out-Null +} + +if ($Action -eq 'Start') { + $builderSource = Join-Path $PSScriptRoot 'build-tcga-lung-dinov2-cohort.ps1' + if (-not (Test-Path -LiteralPath $builderSource -PathType Leaf)) { throw 'TCGA cohort builder is unavailable.' } + $durableLauncher = Join-Path $root 'start-tcga-lung-cohort-build.ps1' + $durableBuilder = Join-Path $root 'build-tcga-lung-dinov2-cohort.ps1' + Copy-Item -LiteralPath $PSCommandPath -Destination $durableLauncher -Force + Copy-Item -LiteralPath $builderSource -Destination $durableBuilder -Force + $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ` + "-NoProfile -ExecutionPolicy Bypass -File `"$durableLauncher`" -Action Run -StateRoot `"$state`" -SelectionProtocol `"$SelectionProtocol`" -FrozenAt `"$FrozenAt`"" + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 7) + $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` + -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew ` + -ExecutionTimeLimit (New-TimeSpan -Hours 12) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Principal $principal ` + -Settings $settings -Description 'Build the offline checksum-bound TCGA lung cohort after acquisition.' -Force | Out-Null + Write-Status 'waiting' 'Waiting for the checksum-verified TCGA acquisition.' + Start-ScheduledTask -TaskName $taskName + Get-Content -LiteralPath $statusPath -Raw + return +} + +if (-not (Test-Path -LiteralPath $acquisitionStatusPath -PathType Leaf)) { + Write-Status 'waiting' 'TCGA acquisition status is unavailable.' + return +} +$acquisition = Get-Content -LiteralPath $acquisitionStatusPath -Raw | ConvertFrom-Json +if ($acquisition.state -ne 'completed') { + Write-Status 'waiting' "TCGA acquisition is $($acquisition.state)." + return +} +Write-Status 'building' 'Extracting offline tissue-aware coordinate-bound tiles.' +try { + $endpoint = Get-Content -LiteralPath (Join-Path $state 'endpoint.json') -Raw | ConvertFrom-Json + $token = [IO.File]::ReadAllText((Join-Path $state 'ipc-token')).Trim() + $headers = @{Authorization="Bearer $token"} + $null = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($endpoint.port)/v1/control/pause" ` + -Headers $headers + try { + $runner = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$($endpoint.port)/v1/status" ` + -Headers $headers + if ([int]$runner.queue.active -ne 0) { throw 'The runner still has active jobs.' } + $derivedUsed = [long]$runner.quota.derived.usedBytes + $derivedRoot = Join-Path $state 'derived' + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $derivedAcl = Get-Acl -LiteralPath $derivedRoot + $derivedSddl = $derivedAcl.Sddl + try { + $temporaryRule = New-Object Security.AccessControl.FileSystemAccessRule( + $currentIdentity, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $derivedAcl.AddAccessRule($temporaryRule) | Out-Null + Set-Acl -LiteralPath $derivedRoot -AclObject $derivedAcl + if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { + & (Join-Path $root 'build-tcga-lung-dinov2-cohort.ps1') -StateRoot $state ` + -DerivedUsedBytes $derivedUsed -SelectionProtocol $SelectionProtocol -FrozenAt $FrozenAt + } + if (-not (Test-Path -LiteralPath $cohortPath -PathType Leaf)) { + throw 'Cohort builder returned without a manifest.' + } + $cohortSha = (Get-FileHash -LiteralPath $cohortPath -Algorithm SHA256).Hash.ToLowerInvariant() + } finally { + $restoredAcl = New-Object Security.AccessControl.DirectorySecurity + $restoredAcl.SetSecurityDescriptorSddlForm($derivedSddl) + Set-Acl -LiteralPath $derivedRoot -AclObject $restoredAcl + } + } finally { + $null = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($endpoint.port)/v1/control/resume" ` + -Headers $headers + Remove-Variable token,headers -ErrorAction SilentlyContinue + } + Write-Status 'completed' "Immutable cohort SHA256: $cohortSha" + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} catch { + Write-Status 'failed' $_.Exception.Message + throw +} diff --git a/scripts/submit-evidence-job.ps1 b/scripts/submit-evidence-job.ps1 new file mode 100644 index 0000000..2e7e06a --- /dev/null +++ b/scripts/submit-evidence-job.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$SlideRevision, + [Parameter(Mandatory = $true)][string]$PreviewPath, + [Parameter(Mandatory = $true)][int]$SourceWidth, + [Parameter(Mandatory = $true)][int]$SourceHeight, + [Parameter(Mandatory = $true)][string]$PackManifest, + [ValidateSet('he', 'ihc_dab')][string]$Stain, + [string]$Marker = 'generic', + [string]$StateRoot +) + +$ErrorActionPreference = 'Stop' +if (-not $StateRoot) { + $StateRoot = if (Test-Path -LiteralPath 'D:\PathLabData' -PathType Container) { + 'D:\PathLabData\EvidenceMentor\state' + } else { + Join-Path $env:LOCALAPPDATA 'PathLab\EvidenceMentor\state' + } +} +$state = [IO.Path]::GetFullPath($StateRoot) +$source = Get-Item -LiteralPath $SourcePath +$preview = Get-Item -LiteralPath $PreviewPath +$pack = Get-Item -LiteralPath $PackManifest +$tokenPath = Join-Path $state 'ipc-token' +$endpointPath = Join-Path $state 'endpoint.json' +if (-not (Test-Path -LiteralPath $tokenPath -PathType Leaf) -or -not (Test-Path -LiteralPath $endpointPath -PathType Leaf)) { + throw 'Evidence Mentor runner is not installed or has not started.' +} +$endpoint = Get-Content -LiteralPath $endpointPath -Raw | ConvertFrom-Json +if ($endpoint.schema -ne 'pathlab.runner-endpoint/1' -or $endpoint.port -lt 1 -or $endpoint.port -gt 65535) { + throw 'Evidence Mentor endpoint is invalid.' +} +$statePrefix = $state.TrimEnd('\') + '\' +foreach ($input in @($source.FullName, $preview.FullName, $pack.FullName)) { + if (-not $input.StartsWith($statePrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "LocalService cannot read an input outside the protected Evidence Mentor state root: $input" + } +} +$jobId = [Guid]::NewGuid().ToString() +$requestRoot = Join-Path $state 'requests' +New-Item -ItemType Directory -Path $requestRoot -Force | Out-Null +$requestPath = Join-Path $requestRoot "$jobId.json" +$request = [ordered]@{ + schema = 'pathlab.evidence-job/2' + sourcePath = $source.FullName + sourceSha256 = (Get-FileHash -LiteralPath $source.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + slideRevision = $SlideRevision + previewPath = $preview.FullName + sourceWidth = $SourceWidth + sourceHeight = $SourceHeight + packManifest = $pack.FullName + stain = $Stain + marker = $Marker.ToLowerInvariant() +} +$partial = "$requestPath.partial" +[IO.File]::WriteAllText($partial, (($request | ConvertTo-Json -Depth 4) + "`n"), + [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath $partial -Destination $requestPath +$token = [IO.File]::ReadAllText($tokenPath).Trim() +$headers = @{ Authorization = "Bearer $token" } +Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($endpoint.port)/v1/jobs" -Headers $headers ` + -ContentType 'application/json' -Body (@{ id = $jobId; requestPath = $requestPath } | ConvertTo-Json) diff --git a/scripts/test-evidence-mentor-service.ps1 b/scripts/test-evidence-mentor-service.ps1 new file mode 100644 index 0000000..817bfe4 --- /dev/null +++ b/scripts/test-evidence-mentor-service.ps1 @@ -0,0 +1,547 @@ +[CmdletBinding(SupportsShouldProcess)] +param( + [ValidateSet('Inspect','ServiceRestart','PrepareReboot','VerifyReboot','Summary')] + [string] $Mode = 'Inspect', + [string] $ProgramRoot = 'C:\ProgramData\PathLab\EvidenceMentor', + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [string] $ReportRoot, + [string] $JobRequestPath, + [string] $JobId, + [ValidateRange(1, 120)] [int] $TimeoutMinutes = 10, + [switch] $AllowIncomplete +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$serviceName = 'PathLabEvidenceMentor' +$expectedWinSwSha256 = '05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da' +$checks = [Collections.Generic.List[object]]::new() +$startedAt = [DateTimeOffset]::UtcNow +if (-not $ReportRoot) { $ReportRoot = Join-Path $StateRoot 'acceptance' } +$ReportRoot = [IO.Path]::GetFullPath($ReportRoot) +$StateRoot = [IO.Path]::GetFullPath($StateRoot) +$ProgramRoot = [IO.Path]::GetFullPath($ProgramRoot) +if ($JobId -and $JobId -notmatch '^acceptance-[a-f0-9]{8,64}$') { + throw 'JobId must be a non-identifying acceptance ID such as acceptance-0123abcd.' +} + +function Add-Check([string] $Name, [string] $Status, [string] $Detail, [hashtable] $Evidence = @{}) { + if ($Status -notin @('PASS','FAIL','NOT_EVALUABLE')) { throw "Invalid acceptance status: $Status" } + $checks.Add([ordered]@{ name = $Name; status = $Status; detail = $Detail; evidence = $Evidence }) +} + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + return [Security.Principal.WindowsPrincipal]::new($identity).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Read-Endpoint { + $path = Join-Path $StateRoot 'endpoint.json' + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } + try { + $endpoint = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + if ($endpoint.schema -ne 'pathlab.runner-endpoint/1' -or $endpoint.port -lt 1 -or $endpoint.port -gt 65535) { return $null } + return $endpoint + } catch { return $null } +} + +function Read-Token { + $path = Join-Path $StateRoot 'ipc-token' + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } + $value = (Get-Content -LiteralPath $path -Raw).Trim() + if ($value.Length -lt 32) { return $null } + return $value +} + +function Invoke-WebRequestCompat([hashtable] $Parameters) { + $request = @{} + $Parameters + if ($PSVersionTable.PSVersion.Major -le 5) { + $request.UseBasicParsing = $true + } else { + $request.SkipHttpErrorCheck = $true + } + try { + return Invoke-WebRequest @request + } catch [Net.WebException] { + $response = $_.Exception.Response + if ($null -eq $response) { throw } + $stream = $response.GetResponseStream() + try { + $reader = [IO.StreamReader]::new($stream) + try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() } + } finally { if ($null -ne $stream) { $stream.Dispose() } } + return [pscustomobject]@{ + StatusCode = [int]$response.StatusCode + Content = $content + Headers = $response.Headers + } + } +} + +function Invoke-Runner([string] $Method, [string] $Path, [object] $Body = $null) { + $endpoint = Read-Endpoint + $token = Read-Token + if ($null -eq $endpoint -or $null -eq $token) { throw 'Runner endpoint or token is unavailable.' } + $parameters = @{ + Method = $Method + Uri = "http://127.0.0.1:$($endpoint.port)$Path" + Headers = @{ Authorization = "Bearer $token" } + TimeoutSec = 3 + } + if ($null -ne $Body) { + $parameters.ContentType = 'application/json' + $parameters.Body = $Body | ConvertTo-Json -Compress -Depth 8 + } + return Invoke-WebRequestCompat $parameters +} + +function Wait-Runner([string] $PreviousBootId = '', [int] $Seconds = 60) { + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($Seconds) + do { + Start-Sleep -Milliseconds 500 + $endpoint = Read-Endpoint + if ($null -ne $endpoint -and ($PreviousBootId -eq '' -or $endpoint.bootId -ne $PreviousBootId)) { + try { + $response = Invoke-Runner 'GET' '/health' + if ($response.StatusCode -eq 200) { return $endpoint } + } catch { } + } + } while ([DateTimeOffset]::UtcNow -lt $deadline) + return $null +} + +function Test-Acl([string] $Path, [string] $Identity, [Security.AccessControl.FileSystemRights] $RequiredRights) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $acl = Get-Acl -LiteralPath $Path + foreach ($entry in $acl.Access) { + $allowsIdentity = $entry.AccessControlType -eq 'Allow' -and $entry.IdentityReference.Value -ieq $Identity + $hasRights = ($entry.FileSystemRights -band $RequiredRights) -eq $RequiredRights + if ($allowsIdentity -and $hasRights) { return $true } + } + return $false +} + +function Write-Atomic([string] $Path, [string] $Content) { + $partial = "$Path.partial" + [IO.File]::WriteAllText($partial, $Content, [Text.UTF8Encoding]::new($false)) + if (Test-Path -LiteralPath $Path -PathType Leaf) { + $backup = "$Path.backup-$([Guid]::NewGuid().ToString('N'))" + try { + [IO.File]::Replace($partial, $Path, $backup) + } finally { + if (Test-Path -LiteralPath $backup -PathType Leaf) { + Remove-Item -LiteralPath $backup -Force + } + } + } else { + [IO.File]::Move($partial, $Path) + } +} + +function Write-AcceptanceReport { + New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null + $failures = @($checks | Where-Object status -eq 'FAIL').Count + $notEvaluable = @($checks | Where-Object status -eq 'NOT_EVALUABLE').Count + $verdict = if ($failures -gt 0) { 'FAIL' } elseif ($notEvaluable -gt 0) { 'NOT_EVALUABLE' } else { 'PASS' } + $report = [ordered]@{ + schema = 'pathlab.service-acceptance/1' + service = $serviceName + runtimeVersion = if (Test-Path -LiteralPath (Join-Path $ProgramRoot 'active-version.txt') -PathType Leaf) { + (Get-Content -LiteralPath (Join-Path $ProgramRoot 'active-version.txt') -Raw).Trim() + } else { '' } + mode = $Mode + startedAt = $startedAt.ToString('O') + completedAt = [DateTimeOffset]::UtcNow.ToString('O') + verdict = $verdict + counts = @{ pass = @($checks | Where-Object status -eq 'PASS').Count; fail = $failures; notEvaluable = $notEvaluable } + privacy = @{ containsTokens = $false; containsSlidePaths = $false; containsScientificOutcomes = $false } + checks = $checks + } + $stamp = [DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss-fff') + $jsonPath = Join-Path $ReportRoot "service-acceptance-$stamp.json" + $markdownPath = Join-Path $ReportRoot "service-acceptance-$stamp.md" + Write-Atomic $jsonPath ($report | ConvertTo-Json -Depth 12) + $lines = [Collections.Generic.List[string]]::new() + $lines.Add('# PathLab Evidence Mentor service acceptance') + $lines.Add('') + $lines.Add("- Verdict: **$verdict**") + $lines.Add("- Mode: ``$Mode``") + $lines.Add("- Completed: $($report.completedAt)") + $lines.Add('') + $lines.Add('| Check | Status | Detail |') + $lines.Add('|---|---|---|') + foreach ($check in $checks) { + $detail = ([string]$check.detail).Replace('|','\|').Replace("`r",' ').Replace("`n",' ') + $lines.Add("| $($check.name) | $($check.status) | $detail |") + } + Write-Atomic $markdownPath ($lines -join [Environment]::NewLine) + [ordered]@{ verdict = $verdict; json = $jsonPath; markdown = $markdownPath } +} + +function Add-StaticChecks { + $service = Get-CimInstance Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue + if ($null -eq $service) { + Add-Check 'service-installed' 'FAIL' 'Windows service is not installed.' + Add-Check 'service-running' 'NOT_EVALUABLE' 'Service installation is required first.' + Add-Check 'service-identity' 'NOT_EVALUABLE' 'Service installation is required first.' + Add-Check 'service-sid-enabled' 'NOT_EVALUABLE' 'Service installation is required first.' + } else { + Add-Check 'service-installed' 'PASS' 'Windows service is installed.' @{ startMode = $service.StartMode } + Add-Check 'service-running' $(if ($service.State -eq 'Running') {'PASS'} else {'FAIL'}) "Service state is $($service.State)." + Add-Check 'service-identity' $(if ($service.StartName -ieq 'NT AUTHORITY\LocalService') {'PASS'} else {'FAIL'}) "Service identity is $($service.StartName)." + $sidType = (& sc.exe qsidtype $serviceName 2>$null) -join [Environment]::NewLine + $sidEnabled = $LASTEXITCODE -eq 0 -and $sidType -match 'UNRESTRICTED' + Add-Check 'service-sid-enabled' $(if ($sidEnabled) {'PASS'} else {'FAIL'}) 'The service SID must be enabled before SID-only runtime ACLs can authorize startup.' + } + + $serviceRegistry = "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName" + if (Test-Path -LiteralPath $serviceRegistry) { + $registry = Get-ItemProperty -LiteralPath $serviceRegistry + $delayedProperty = $registry.PSObject.Properties['DelayedAutostart'] + $failureProperty = $registry.PSObject.Properties['FailureActions'] + $delayed = $null -ne $delayedProperty -and $delayedProperty.Value -eq 1 + $recovery = $null -ne $failureProperty -and $null -ne $failureProperty.Value -and $failureProperty.Value.Length -gt 0 + Add-Check 'delayed-auto-start' $(if ($delayed) {'PASS'} else {'FAIL'}) 'Delayed automatic startup registry flag.' @{ enabled = $delayed } + Add-Check 'service-recovery-policy' $(if ($recovery) {'PASS'} else {'FAIL'}) 'Windows service recovery actions are configured.' + } else { + Add-Check 'delayed-auto-start' 'NOT_EVALUABLE' 'Service registry key is absent.' + Add-Check 'service-recovery-policy' 'NOT_EVALUABLE' 'Service registry key is absent.' + } + + $activeVersionPath = Join-Path $ProgramRoot 'active-version.txt' + $wrapperPath = Join-Path $ProgramRoot 'PathLabEvidenceMentor.exe' + if (Test-Path -LiteralPath $wrapperPath -PathType Leaf) { + $sha = (Get-FileHash -LiteralPath $wrapperPath -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Check 'winsw-checksum' $(if ($sha -eq $expectedWinSwSha256) {'PASS'} else {'FAIL'}) 'Installed WinSW must match the pinned checksum.' @{ sha256 = $sha } + } else { Add-Check 'winsw-checksum' 'NOT_EVALUABLE' 'WinSW wrapper is not installed.' } + if (Test-Path -LiteralPath $activeVersionPath -PathType Leaf) { + Add-Check 'active-runtime' 'PASS' 'An active side-by-side runtime version is recorded.' @{ version = (Get-Content -LiteralPath $activeVersionPath -Raw).Trim() } + } else { Add-Check 'active-runtime' 'NOT_EVALUABLE' 'Active runtime marker is absent.' } + + $serviceIdentity = "NT SERVICE\$serviceName" + Add-Check 'runtime-acl' $(if (Test-Acl $ProgramRoot $serviceIdentity ([Security.AccessControl.FileSystemRights]::ReadAndExecute)) {'PASS'} else {'FAIL'}) 'Service SID requires runtime read/execute access.' + Add-Check 'state-acl' $(if (Test-Acl $StateRoot $serviceIdentity ([Security.AccessControl.FileSystemRights]::Modify)) {'PASS'} else {'FAIL'}) 'Service SID requires state modify access.' + + try { + $rules = @(Get-NetFirewallRule -DisplayName 'PathLab Evidence Mentor outbound deny*' -ErrorAction Stop | + Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Outbound' -and $_.Action -eq 'Block' }) + $expectedExecutables = [Collections.Generic.List[string]]::new() + $activeRuntime = if (Test-Path -LiteralPath $activeVersionPath -PathType Leaf) { + (Get-Content -LiteralPath $activeVersionPath -Raw).Trim() + } else { '' } + if ($activeRuntime) { + $runtimeJava = Join-Path $ProgramRoot "runtime\$activeRuntime\jre\bin\java.exe" + if (Test-Path -LiteralPath $runtimeJava -PathType Leaf) { $expectedExecutables.Add([IO.Path]::GetFullPath($runtimeJava)) } + } + $modelRoot = Join-Path $StateRoot 'models' + if (Test-Path -LiteralPath $modelRoot -PathType Container) { + foreach ($worker in Get-ChildItem -LiteralPath $modelRoot -Filter '*.exe' -File -Recurse) { + $expectedExecutables.Add($worker.FullName) + } + } + $blockedExecutables = @($rules | Get-NetFirewallApplicationFilter | ForEach-Object { [IO.Path]::GetFullPath($_.Program) }) + $missing = @($expectedExecutables | Where-Object { $_ -notin $blockedExecutables }) + $covered = $expectedExecutables.Count -gt 0 -and $missing.Count -eq 0 + Add-Check 'outbound-deny-rules' $(if ($covered) {'PASS'} else {'FAIL'}) 'Every installed analysis executable requires an enabled outbound-deny rule.' @{ + expectedExecutableCount = $expectedExecutables.Count; ruleCount = $rules.Count; missingCount = $missing.Count + } + } catch { Add-Check 'outbound-deny-rules' 'NOT_EVALUABLE' 'Firewall rules could not be inspected.' } + + $endpoint = Read-Endpoint + if ($null -eq $endpoint) { + Add-Check 'dynamic-endpoint' 'FAIL' 'A valid endpoint.json is not published.' + Add-Check 'authenticated-health' 'NOT_EVALUABLE' 'Runner endpoint is unavailable.' + Add-Check 'dashboard-session-security' 'NOT_EVALUABLE' 'Runner endpoint is unavailable.' + } else { + Add-Check 'dynamic-endpoint' 'PASS' 'Runner published a token-free dynamic loopback endpoint.' @{ + port = [int]$endpoint.port; bootId = [string]$endpoint.bootId; serviceVersion = [string]$endpoint.serviceVersion + } + try { + $health = Invoke-Runner 'GET' '/health' + Add-Check 'authenticated-health' $(if ($health.StatusCode -eq 200) {'PASS'} else {'FAIL'}) 'Authenticated loopback health response.' @{ statusCode = $health.StatusCode } + } catch { Add-Check 'authenticated-health' 'FAIL' 'Authenticated health request failed.' } + try { + $origin = "http://127.0.0.1:$($endpoint.port)" + $unauthorized = Invoke-WebRequestCompat @{ Uri = "$origin/v1/status"; TimeoutSec = 3 } + $created = Invoke-Runner 'POST' '/v1/dashboard-sessions' + $code = ($created.Content | ConvertFrom-Json).code + $exchangeBody = @{ code = $code } | ConvertTo-Json -Compress + $first = Invoke-WebRequestCompat @{ Method = 'Post'; Uri = "$origin/v1/dashboard-session/exchange"; + Headers = @{ Origin = $origin }; ContentType = 'application/json'; Body = $exchangeBody; TimeoutSec = 3 } + $reuse = Invoke-WebRequestCompat @{ Method = 'Post'; Uri = "$origin/v1/dashboard-session/exchange"; + Headers = @{ Origin = $origin }; ContentType = 'application/json'; Body = $exchangeBody; TimeoutSec = 3 } + $setCookie = [string]($first.Headers['Set-Cookie'] -join ';') + $secure = $unauthorized.StatusCode -eq 401 -and $created.StatusCode -eq 201 ` + -and $first.StatusCode -eq 200 -and $reuse.StatusCode -eq 401 ` + -and $setCookie -match 'HttpOnly' -and $setCookie -match 'SameSite=Strict' + Add-Check 'dashboard-session-security' $(if ($secure) {'PASS'} else {'FAIL'}) 'Bearer protection and one-time dashboard exchange.' @{ + unauthorized = $unauthorized.StatusCode; created = $created.StatusCode; exchanged = $first.StatusCode; reused = $reuse.StatusCode + } + } catch { Add-Check 'dashboard-session-security' 'FAIL' 'Dashboard session security check failed.' } + } + + try { + $gpu = & nvidia-smi.exe --query-gpu=name,driver_version,memory.total --format=csv,noheader,nounits 2>$null + if ($LASTEXITCODE -eq 0 -and $gpu) { + $parts = (@($gpu)[0]).Split(',') + Add-Check 'gpu-visible' 'PASS' 'NVIDIA GPU telemetry is visible to the current acceptance process.' @{ + name = $parts[0].Trim(); driver = $parts[1].Trim(); memoryMiB = [int]($parts[2].Trim()) + } + } else { Add-Check 'gpu-visible' 'FAIL' 'nvidia-smi did not return GPU telemetry.' } + } catch { Add-Check 'gpu-visible' 'FAIL' 'nvidia-smi is unavailable.' } +} + +function Submit-BoundedJob { + if (-not $JobRequestPath) { Add-Check 'bounded-job' 'NOT_EVALUABLE' 'No acceptance job request was supplied.'; return $null } + $request = [IO.Path]::GetFullPath($JobRequestPath) + $statePrefix = $StateRoot.TrimEnd('\') + '\' + if (-not $request.StartsWith($statePrefix, [StringComparison]::OrdinalIgnoreCase)) { + Add-Check 'bounded-job' 'FAIL' 'Acceptance request must be staged under the protected state root.' + return $null + } + if (-not (Test-Path -LiteralPath $request -PathType Leaf)) { Add-Check 'bounded-job' 'FAIL' 'Acceptance request file is missing.'; return $null } + $resolvedJobId = if ($JobId) { $JobId } else { 'acceptance-' + [Guid]::NewGuid().ToString('N') } + try { + $response = Invoke-Runner 'POST' '/v1/jobs' @{ id = $resolvedJobId; requestPath = $request } + if ($response.StatusCode -ne 202) { Add-Check 'bounded-job-submission' 'FAIL' "Runner returned HTTP $($response.StatusCode)."; return $null } + Add-Check 'bounded-job-submission' 'PASS' 'Runner accepted ownership of the immutable bounded request.' @{ jobId = $resolvedJobId } + return $resolvedJobId + } catch { Add-Check 'bounded-job-submission' 'FAIL' 'Bounded job submission failed.'; return $null } +} + +function Test-Session0GpuResult([string] $ResolvedJobId, [object] $Job) { + $resultPath = Join-Path $StateRoot "worker-output\$ResolvedJobId\result.json" + try { + if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) { + return [pscustomobject]@{ Passed = $false; Evidence = @{ reason = 'worker-result-missing' } } + } + $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + $runtime = $result.runtime + $passed = $result.schema -eq 'pathlab.model-worker-result/1' -and + $result.status -eq 'completed' -and + $result.packManifestSha256 -eq $Job.packSha256 -and + [string]$runtime.device -match 'Quadro P2000' -and + [string]$runtime.cuda -eq '12.6' -and + [string]$runtime.architecture -eq 'sm_61' -and + [string]$runtime.analysisNetwork -eq 'disabled' -and + [int]$runtime.tiles -gt 0 -and + [double]$runtime.elapsedSeconds -gt 0 -and + [double]$runtime.peakVramMiB -gt 0 -and + [double]$runtime.peakVramMiB -le 4608 -and + [double]$runtime.peakRamMiB -gt 0 -and + [double]$runtime.peakRamMiB -le 16384 + return [pscustomobject]@{ + Passed = $passed + Evidence = @{ + resultSha256 = (Get-FileHash -LiteralPath $resultPath -Algorithm SHA256).Hash.ToLowerInvariant() + device = [string]$runtime.device + cuda = [string]$runtime.cuda + architecture = [string]$runtime.architecture + tiles = [int]$runtime.tiles + batchSize = [int]$runtime.batchSize + elapsedSeconds = [double]$runtime.elapsedSeconds + peakVramMiB = [double]$runtime.peakVramMiB + peakRamMiB = [double]$runtime.peakRamMiB + analysisNetwork = [string]$runtime.analysisNetwork + } + } + } catch { + return [pscustomobject]@{ Passed = $false; Evidence = @{ reason = 'worker-result-invalid' } } + } +} + +function Wait-Job([string] $ResolvedJobId, [bool] $RestartWhenActive) { + if (-not $ResolvedJobId) { return $false } + $deadline = [DateTimeOffset]::UtcNow.AddMinutes($TimeoutMinutes) + $restarted = $false + $pollMilliseconds = if ($RestartWhenActive) { 50 } else { 1000 } + do { + try { + $response = Invoke-Runner 'GET' "/v1/jobs/$ResolvedJobId" + if ($response.StatusCode -ne 200) { Start-Sleep -Milliseconds $pollMilliseconds; continue } + $job = $response.Content | ConvertFrom-Json + if ($RestartWhenActive -and -not $restarted -and $job.state -in @('validating','running','refining','packaging')) { + if (-not (Test-Administrator)) { Add-Check 'service-restart' 'NOT_EVALUABLE' 'Administrator privileges are required.'; $RestartWhenActive = $false; continue } + $before = Read-Endpoint + if ($PSCmdlet.ShouldProcess($serviceName, "Restart service while bounded job $ResolvedJobId is active")) { + Restart-Service -Name $serviceName -Force + $after = Wait-Runner -PreviousBootId $before.bootId -Seconds 60 + if ($null -eq $after) { Add-Check 'service-restart' 'FAIL' 'Runner did not return with a new boot ID.'; return $false } + Add-Check 'service-restart' 'PASS' 'Runner returned after restart with a new boot ID.' @{ previousBootId = $before.bootId; bootId = $after.bootId } + $restarted = $true + } + } + if ($job.state -in @('completed','abstained','unsupported','failed','cancelled')) { + $passed = $job.state -in @('completed','abstained') -and $job.finalArtifactSha256 -match '^[a-f0-9]{64}$' + Add-Check 'bounded-job-completion' $(if ($passed) {'PASS'} else {'FAIL'}) 'Bounded job reached a terminal state.' @{ + jobId = $ResolvedJobId; state = $job.state; lane = $job.lane; failureCode = $job.failureCode; finalArtifactSha256 = $job.finalArtifactSha256 + } + if ($job.lane -eq 'gpu') { + $gpuResult = Test-Session0GpuResult $ResolvedJobId $job + $gpuPassed = $passed -and $gpuResult.Passed + $gpuEvidence = @{} + $gpuResult.Evidence + $gpuEvidence.jobId = $ResolvedJobId + $gpuEvidence.state = $job.state + Add-Check 'session0-gpu-inference' $(if ($gpuPassed) {'PASS'} else {'FAIL'}) 'The retained worker result proves bounded offline CUDA inference on the P2000.' $gpuEvidence + } else { Add-Check 'session0-gpu-inference' 'NOT_EVALUABLE' 'The bounded acceptance job did not use the GPU lane.' } + return $passed + } + } catch { } + Start-Sleep -Milliseconds $pollMilliseconds + } while ([DateTimeOffset]::UtcNow -lt $deadline) + Add-Check 'bounded-job-completion' 'FAIL' 'Bounded job did not complete before the acceptance timeout.' @{ jobId = $ResolvedJobId } + return $false +} + +function Wait-JobActive([string] $ResolvedJobId, [int] $Seconds = 60) { + if (-not $ResolvedJobId) { return $false } + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($Seconds) + do { + try { + $response = Invoke-Runner 'GET' "/v1/jobs/$ResolvedJobId" + if ($response.StatusCode -ne 200) { Start-Sleep -Seconds 1; continue } + $job = $response.Content | ConvertFrom-Json + if ($job.state -in @('validating','running','refining','packaging')) { + Add-Check 'reboot-job-active' 'PASS' 'The bounded job is durably active before manual reboot.' @{ + jobId = $ResolvedJobId; state = $job.state; lane = $job.lane; updatedAt = $job.updatedAt + } + return $true + } + if ($job.state -in @('completed','abstained','unsupported','failed','cancelled')) { + Add-Check 'reboot-job-active' 'FAIL' 'The bounded job reached a terminal state before the reboot challenge.' @{ + jobId = $ResolvedJobId; state = $job.state + } + return $false + } + } catch { } + Start-Sleep -Seconds 1 + } while ([DateTimeOffset]::UtcNow -lt $deadline) + Add-Check 'reboot-job-active' 'FAIL' 'The bounded job did not become active before the reboot challenge timeout.' @{ jobId = $ResolvedJobId } + return $false +} + +function Prepare-RebootChallenge([string] $ResolvedJobId) { + $endpoint = Read-Endpoint + if ($null -eq $endpoint) { Add-Check 'reboot-challenge' 'FAIL' 'Runner endpoint is required before preparing reboot verification.'; return } + $os = Get-CimInstance Win32_OperatingSystem + $jobAtChallenge = $null + if ($ResolvedJobId) { + try { + $jobResponse = Invoke-Runner 'GET' "/v1/jobs/$ResolvedJobId" + if ($jobResponse.StatusCode -eq 200) { $jobAtChallenge = $jobResponse.Content | ConvertFrom-Json } + } catch { } + } + $challenge = [ordered]@{ + schema = 'pathlab.service-reboot-challenge/1' + createdAt = [DateTimeOffset]::UtcNow.ToString('O') + osBootTime = ([DateTimeOffset]$os.LastBootUpTime).ToUniversalTime().ToString('O') + runnerBootId = [string]$endpoint.bootId + jobId = if ($ResolvedJobId) { $ResolvedJobId } else { '' } + jobState = if ($null -ne $jobAtChallenge) { [string]$jobAtChallenge.state } else { '' } + jobUpdatedAt = if ($null -ne $jobAtChallenge) { [string]$jobAtChallenge.updatedAt } else { '' } + } + New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null + Write-Atomic (Join-Path $ReportRoot 'reboot-challenge.json') ($challenge | ConvertTo-Json -Depth 5) + Add-Check 'reboot-challenge' 'PASS' 'Challenge recorded. Reboot manually, then run VerifyReboot.' @{ hasJob = [bool]$ResolvedJobId } +} + +function Verify-RebootChallenge { + $path = Join-Path $ReportRoot 'reboot-challenge.json' + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Add-Check 'reboot-continuation' 'NOT_EVALUABLE' 'No reboot challenge is present.'; return } + $challenge = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + if ($challenge.schema -ne 'pathlab.service-reboot-challenge/1') { Add-Check 'reboot-continuation' 'FAIL' 'Reboot challenge schema is invalid.'; return } + $os = Get-CimInstance Win32_OperatingSystem + $currentBoot = ([DateTimeOffset]$os.LastBootUpTime).ToUniversalTime() + $endpoint = Wait-Runner -Seconds 60 + $passed = $currentBoot -gt [DateTimeOffset]::Parse($challenge.osBootTime) -and $null -ne $endpoint -and $endpoint.bootId -ne $challenge.runnerBootId + Add-Check 'reboot-continuation' $(if ($passed) {'PASS'} else {'FAIL'}) 'OS and runner boot identities must both change and recover without login.' @{ + osBootChanged = $currentBoot -gt [DateTimeOffset]::Parse($challenge.osBootTime) + runnerBootChanged = $null -ne $endpoint -and $endpoint.bootId -ne $challenge.runnerBootId + } + if ($passed -and $challenge.jobId) { + $continued = Wait-Job ([string]$challenge.jobId) $false + $completedAfterBoot = $false + $jobUpdatedAt = '' + try { + $jobResponse = Invoke-Runner 'GET' "/v1/jobs/$([string]$challenge.jobId)" + if ($jobResponse.StatusCode -eq 200) { + $job = $jobResponse.Content | ConvertFrom-Json + $jobUpdatedAt = [string]$job.updatedAt + $completedAfterBoot = [DateTimeOffset]::Parse($jobUpdatedAt) -gt $currentBoot + } + } catch { } + $continued = $continued -and $completedAfterBoot + Add-Check 'reboot-job-continuation' $(if ($continued) {'PASS'} else {'FAIL'}) 'The pre-reboot bounded job must complete after the new OS boot, not before it.' @{ + jobId = [string]$challenge.jobId; jobUpdatedAt = $jobUpdatedAt; currentBoot = $currentBoot.ToString('O'); completedAfterBoot = $completedAfterBoot + } + } elseif (-not $challenge.jobId) { + Add-Check 'reboot-job-continuation' 'NOT_EVALUABLE' 'The reboot challenge did not include a bounded job.' + } +} + +function Add-SummaryChecks { + $required = @( + 'bounded-job-submission','bounded-job-completion','session0-gpu-inference', + 'service-restart','reboot-job-active','reboot-continuation','reboot-job-continuation' + ) + $currentRuntime = if (Test-Path -LiteralPath (Join-Path $ProgramRoot 'active-version.txt') -PathType Leaf) { + (Get-Content -LiteralPath (Join-Path $ProgramRoot 'active-version.txt') -Raw).Trim() + } else { '' } + $reports = [Collections.Generic.List[object]]::new() + if (Test-Path -LiteralPath $ReportRoot -PathType Container) { + foreach ($file in Get-ChildItem -LiteralPath $ReportRoot -Filter 'service-acceptance-*.json' -File) { + try { + $report = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + if ($report.schema -eq 'pathlab.service-acceptance/1' -and $report.mode -ne 'Summary' ` + -and $currentRuntime -and $report.runtimeVersion -eq $currentRuntime) { $reports.Add($report) } + } catch { } + } + } + foreach ($name in $required) { + $passing = @($reports | ForEach-Object { $_.checks } | Where-Object { $_.name -eq $name -and $_.status -eq 'PASS' } | + Select-Object -First 1) + if ($passing.Count -gt 0) { + Add-Check $name 'PASS' 'A prior host acceptance report contains passing evidence.' + } else { + Add-Check $name 'NOT_EVALUABLE' 'No passing host acceptance report is available yet.' + } + } +} + +if ($Mode -eq 'Summary') { + Add-StaticChecks + Add-SummaryChecks +} else { + Add-StaticChecks + $resolvedJobId = $null + if ($JobRequestPath) { $resolvedJobId = Submit-BoundedJob } + switch ($Mode) { + 'ServiceRestart' { + if ($null -eq $resolvedJobId) { Add-Check 'service-restart' 'NOT_EVALUABLE' 'A bounded job request is required for restart recovery.' } + else { + $null = Wait-Job $resolvedJobId $true + if (@($checks | Where-Object name -eq 'service-restart').Count -eq 0) { + Add-Check 'service-restart' 'FAIL' 'The bounded job finished or timed out before service restart recovery was demonstrated.' + } + } + } + 'PrepareReboot' { + if ($null -eq $resolvedJobId) { + Add-Check 'reboot-job-active' 'NOT_EVALUABLE' 'A bounded job request is required for reboot recovery acceptance.' + } elseif (-not (Wait-JobActive $resolvedJobId)) { + $resolvedJobId = $null + } + Prepare-RebootChallenge $resolvedJobId + } + 'VerifyReboot' { Verify-RebootChallenge } + default { if ($null -ne $resolvedJobId) { $null = Wait-Job $resolvedJobId $false } } + } +} + +$result = Write-AcceptanceReport +$result | ConvertTo-Json -Compress +if (-not $AllowIncomplete) { + if ($result.verdict -eq 'FAIL') { exit 1 } + if ($result.verdict -eq 'NOT_EVALUABLE') { exit 2 } +} diff --git a/scripts/watch-evidence-acquisition.ps1 b/scripts/watch-evidence-acquisition.ps1 new file mode 100644 index 0000000..8796f3e --- /dev/null +++ b/scripts/watch-evidence-acquisition.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param( + [string] $StateRoot = 'D:\PathLabData\EvidenceMentor\state', + [ValidateRange(1, 60)] + [int] $RefreshSeconds = 2, + [switch] $Once +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$acquisitionRoot = Join-Path ([IO.Path]::GetFullPath($StateRoot)) 'acquisition' + +function Read-AcquisitionStatuses { + if (-not (Test-Path -LiteralPath $acquisitionRoot -PathType Container)) { return @() } + $results = foreach ($directory in Get-ChildItem -LiteralPath $acquisitionRoot -Directory) { + $statusPath = Join-Path $directory.FullName 'status.json' + if (-not (Test-Path -LiteralPath $statusPath -PathType Leaf)) { continue } + try { + $status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json + if ($status.schema -ne 'pathlab.acquisition-status/1' -or + [long]$status.completedBytes -lt 0 -or [long]$status.totalBytes -le 0 -or + [long]$status.completedBytes -gt [long]$status.totalBytes) { continue } + [pscustomobject]@{ + Dataset = [string]$status.datasetId + State = [string]$status.state + Percent = '{0:N1}%' -f (100 * [double]$status.completedBytes / [double]$status.totalBytes) + ProgressGiB = '{0:N2} / {1:N2}' -f ([double]$status.completedBytes / 1GB), + ([double]$status.totalBytes / 1GB) + Detail = [string]$status.detail + Updated = ([DateTimeOffset]::Parse([string]$status.updatedAt)).ToLocalTime().ToString('yyyy-MM-dd HH:mm:ss') + } + } catch { + # Status files are replaced atomically; a transient read failure must not stop monitoring. + } + } + return @($results | Sort-Object Dataset) +} + +do { + Clear-Host + Write-Host 'PathLab Evidence Mentor - Data Acquisition' -ForegroundColor Cyan + Write-Host "State root: $StateRoot" + Write-Host "Updated: $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))" + Write-Host '' + $statuses = @(Read-AcquisitionStatuses) + if ($statuses.Count -eq 0) { + Write-Host 'No acquisition records are available.' -ForegroundColor Yellow + } else { + $statuses | Format-Table Dataset, State, Percent, ProgressGiB, Updated -AutoSize + foreach ($status in $statuses) { + Write-Host "$($status.Dataset): $($status.Detail)" + } + } + if (-not $Once) { + Write-Host '' + Write-Host 'Refreshing automatically. Press Ctrl+C to close.' -ForegroundColor DarkGray + Start-Sleep -Seconds $RefreshSeconds + } +} while (-not $Once) diff --git a/src/main/java/org/pathlab/forge/evidence/AtlasFeasibilityGate.java b/src/main/java/org/pathlab/forge/evidence/AtlasFeasibilityGate.java new file mode 100644 index 0000000..5ec22c0 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/AtlasFeasibilityGate.java @@ -0,0 +1,53 @@ +package org.pathlab.forge.evidence; + +import java.time.Duration; +import java.util.List; + +/** Frozen four-hour gate for resumable 15-20 minute Atlas probe segments. */ +public final class AtlasFeasibilityGate { + public static final Duration REQUIRED_PROBE = Duration.ofHours(4); + public static final Duration MIN_SEGMENT = Duration.ofMinutes(15); + public static final Duration MAX_SEGMENT = Duration.ofMinutes(20); + public static final Duration MAX_PROJECTED_COMPLETION = Duration.ofDays(7); + public static final long MAX_RAM_BYTES = 16L * 1024 * 1024 * 1024; + public static final long MAX_VRAM_BYTES = 4608L * 1024 * 1024; + + private AtlasFeasibilityGate() { } + + public static Decision evaluate(List segments, Duration projectedCompletion) { + if (segments == null || segments.isEmpty() || projectedCompletion == null) { + return new Decision("not_evaluable", "ATLAS_PROBE_MISSING", false); + } + if (segments.stream().anyMatch(segment -> segment.duration().compareTo(MIN_SEGMENT) < 0 + || segment.duration().compareTo(MAX_SEGMENT) > 0 || !segment.checkpointVerified())) { + return new Decision("not_evaluable", "ATLAS_SEGMENT_OR_CHECKPOINT_INVALID", false); + } + if (segments.stream().anyMatch(segment -> segment.peakRamBytes() > MAX_RAM_BYTES + || segment.peakVramBytes() > MAX_VRAM_BYTES)) { + return new Decision("experimental", "LOCAL_INSUFFICIENT_RESOURCE_LIMIT", true); + } + var elapsed = segments.stream().map(Segment::duration).reduce(Duration.ZERO, Duration::plus); + if (elapsed.compareTo(REQUIRED_PROBE) < 0) { + return new Decision("not_evaluable", "ATLAS_FOUR_HOUR_PROBE_INCOMPLETE", false); + } + var first = segments.get(0).validationMetric(); + var last = segments.get(segments.size() - 1).validationMetric(); + if (!(Double.isFinite(first) && Double.isFinite(last) && last > first)) { + return new Decision("experimental", "LOCAL_INSUFFICIENT_NO_VALIDATION_IMPROVEMENT", true); + } + if (projectedCompletion.compareTo(MAX_PROJECTED_COMPLETION) > 0) { + return new Decision("experimental", "LOCAL_INSUFFICIENT_PROJECTED_OVER_SEVEN_DAYS", true); + } + return new Decision("experimental", "LOCAL_PROBE_PASSED_FULL_QUALIFICATION_PENDING", false); + } + + public static void requireQuote(long allInUsdCents, int gpuMemoryGiB) { + if (allInUsdCents < 1 || allInUsdCents > 10_000 || gpuMemoryGiB < 24) { + throw new IllegalArgumentException("Atlas rental quote exceeds the frozen approval envelope"); + } + } + + public record Segment(Duration duration, long peakRamBytes, long peakVramBytes, + double validationMetric, boolean checkpointVerified) { } + public record Decision(String status, String reason, boolean paidComputeQuoteAllowed) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/BrightfieldQualificationHarness.java b/src/main/java/org/pathlab/forge/evidence/BrightfieldQualificationHarness.java new file mode 100644 index 0000000..d42ffa5 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/BrightfieldQualificationHarness.java @@ -0,0 +1,247 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; + +/** Runs bounded synthetic checks that inform qualification without qualifying a pack. */ +public final class BrightfieldQualificationHarness { + public static final String SCHEMA = "pathlab.model-qualification-report/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String PROTOCOL = String.join("\n", + "pathlab-brightfield-synthetic-qualification-v1", + "separated-nuclei-count=2", + "touching-nuclei-separation=2", + "generic-dab-area=0.2+-1e-12", + "weak-separation=not_evaluable", + "marker-specific=not_evaluable-until-independent-fixtures", + "pd-l1-compartments=not_evaluable-without-reviewed-geometry"); + + private BrightfieldQualificationHarness() {} + + public static void main(String[] arguments) throws Exception { + Path packs = null; + Path output = null; + for (var index = 0; index < arguments.length; index++) { + if ("--packs".equals(arguments[index]) && index + 1 < arguments.length) { + packs = Path.of(arguments[++index]); + } else if ("--output".equals(arguments[index]) && index + 1 < arguments.length) { + output = Path.of(arguments[++index]); + } else { + throw new IllegalArgumentException( + "Usage: BrightfieldQualificationHarness --packs PATH --output FILE"); + } + } + if (packs == null || output == null) { + throw new IllegalArgumentException( + "Usage: BrightfieldQualificationHarness --packs PATH --output FILE"); + } + run(packs, output, Instant.now()); + System.out.println("Synthetic qualification report written: " + output.toAbsolutePath().normalize()); + System.out.println("Outcome: experimental (never an activation decision)"); + } + + static void run(Path packRoot, Path output, Instant generatedAt) throws IOException { + if (packRoot == null || output == null || generatedAt == null) { + throw new IllegalArgumentException("Qualification inputs are required"); + } + var normalizedRoot = packRoot.toAbsolutePath().normalize(); + var cellPack = EvidencePackManifest.load(normalizedRoot.resolve("cell-od-watershed-v1.json")); + var ihcPack = EvidencePackManifest.load(normalizedRoot.resolve("ihc-descriptive-v1.json")); + require(cellPack.capability() == EvidencePackManifest.Capability.CELL_MORPHOLOGY, + "Cell qualification pack capability is invalid"); + require(ihcPack.capability() == EvidencePackManifest.Capability.IHC_DESCRIPTIVE, + "IHC qualification pack capability is invalid"); + require(cellPack.validationStatus() == EvidencePackManifest.ValidationStatus.EXPERIMENTAL + && ihcPack.validationStatus() == EvidencePackManifest.ValidationStatus.EXPERIMENTAL, + "Synthetic harness may run only against experimental brightfield packs"); + + var report = JSON.createObjectNode(); + report.put("schema", SCHEMA); + report.put("protocolId", "pathlab-brightfield-synthetic-qualification-v1"); + report.put("protocolSha256", sha256(PROTOCOL.getBytes(StandardCharsets.UTF_8))); + report.put("generatedAt", generatedAt.toString()); + report.put("syntheticOnly", true); + report.put("researchOnly", true); + report.put("notDiagnostic", true); + report.put("overallStatus", "experimental"); + var tracks = report.putArray("tracks"); + tracks.add(cellTrack(cellPack)); + tracks.add(ihcTrack(ihcPack)); + + var normalizedOutput = output.toAbsolutePath().normalize(); + var parent = normalizedOutput.getParent(); + if (parent == null) throw new IllegalArgumentException("Qualification output parent is unavailable"); + Files.createDirectories(parent); + var partial = normalizedOutput.resolveSibling(normalizedOutput.getFileName() + ".partial"); + try { + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), report); + Files.move(partial, normalizedOutput, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(partial); + } + } + + private static ObjectNode cellTrack(EvidencePackManifest pack) { + var separated = BrightfieldTileAnalyzer.analyze(separatedNuclei(), "generic"); + var touching = BrightfieldTileAnalyzer.analyze(touchingNuclei(), "generic"); + var repeated = BrightfieldTileAnalyzer.analyze(separatedNuclei(), "generic"); + var track = track(pack, "cell-morphology", "experimental"); + var checks = track.putArray("checks"); + checks.add(numericCheck("separated-nuclei-count", separated.cellCount(), 2, + separated.cellCount() == 2 ? "pass" : "fail")); + checks.add(numericCheck("touching-nuclei-separation", touching.cellCount(), 2, + touching.cellCount() == 2 ? "pass" : "fail")); + checks.add(textCheck("deterministic-repeat", + separated.equals(repeated) ? "pass" : "fail", + "Exact repeated analysis result")); + checks.add(textCheck("instance-mask-output", + touching.instances().size() == 2 + && touching.instances().stream().allMatch(instance -> !instance.rle().isEmpty()) + ? "pass" : "fail", + "Deterministic reviewed-region RLE instance masks")); + var reasons = track.putArray("reasons"); + reasons.add("CROSS_TISSUE_HELD_OUT_FIXTURES_PENDING"); + return track; + } + + private static ObjectNode ihcTrack(EvidencePackManifest pack) { + var dab = BrightfieldTileAnalyzer.analyze(dabAreaFixture(), "generic"); + var mixedQc = BrightfieldStainQc.inspect(mixedStainFixture(), false); + var weakQc = BrightfieldStainQc.inspect(weakSeparationFixture(), false); + var track = track(pack, "ihc-descriptive", "experimental"); + var checks = track.putArray("checks"); + var dabCheck = JSON.createObjectNode(); + dabCheck.put("id", "generic-dab-area"); + dabCheck.put("outcome", Math.abs(dab.dabAreaFraction() - 0.2) <= 1e-12 ? "pass" : "fail"); + dabCheck.put("observed", dab.dabAreaFraction()); + dabCheck.put("required", 0.2); + dabCheck.put("tolerance", 1e-12); + checks.add(dabCheck); + checks.add(textCheck("relative-only-without-controls", + "relative_only".equals(mixedQc.calibrationStatus()) ? "pass" : "fail", + mixedQc.calibrationStatus())); + checks.add(textCheck("weak-separation-refusal", + "not_evaluable".equals(weakQc.calibrationStatus()) + && weakQc.reasons().contains("weak_stain_separation") ? "pass" : "fail", + weakQc.calibrationStatus())); + checks.add(textCheck("marker-specific-measurement", "not_evaluable", + "Nuclear and membrane algorithms await independent fixtures")); + checks.add(textCheck("pd-l1-compartment-measurement", "not_evaluable", + "Reviewed compartment geometry is not present in the v1 request")); + var reasons = track.putArray("reasons"); + reasons.add("VALIDATED_STAIN_VECTOR_DECONVOLUTION_PENDING"); + reasons.add("MARKER_SPECIFIC_FIXTURES_PENDING"); + reasons.add("REVIEWED_COMPARTMENT_GEOMETRY_PENDING"); + return track; + } + + private static ObjectNode track( + EvidencePackManifest pack, String capability, String status) { + var track = JSON.createObjectNode(); + track.put("capability", capability); + track.put("packId", pack.packId()); + track.put("packVersion", pack.version()); + track.put("packManifestSha256", pack.sha256()); + track.put("status", status); + return track; + } + + private static ObjectNode numericCheck(String id, int observed, int required, String outcome) { + var check = JSON.createObjectNode(); + check.put("id", id); + check.put("outcome", outcome); + check.put("observed", observed); + check.put("required", required); + return check; + } + + private static ObjectNode textCheck(String id, String outcome, String detail) { + var check = JSON.createObjectNode(); + check.put("id", id); + check.put("outcome", outcome); + check.put("detail", detail); + return check; + } + + private static BufferedImage separatedNuclei() { + var image = whiteImage(24, 16); + var graphics = image.createGraphics(); + graphics.setColor(new Color(75, 45, 125)); + graphics.fillRect(3, 5, 5, 5); + graphics.fillRect(15, 5, 5, 5); + graphics.dispose(); + return image; + } + + private static BufferedImage touchingNuclei() { + var image = whiteImage(24, 16); + var graphics = image.createGraphics(); + graphics.setColor(new Color(75, 45, 125)); + graphics.fillRect(6, 5, 5, 5); + graphics.fillRect(11, 5, 5, 5); + graphics.dispose(); + return image; + } + + private static BufferedImage dabAreaFixture() { + var image = whiteImage(10, 10); + var graphics = image.createGraphics(); + graphics.setColor(new Color(135, 90, 45)); + graphics.fillRect(0, 0, 10, 2); + graphics.dispose(); + return image; + } + + private static BufferedImage mixedStainFixture() { + var image = whiteImage(16, 16); + var graphics = image.createGraphics(); + graphics.setColor(new Color(75, 45, 125)); + graphics.fillRect(0, 0, 8, 16); + graphics.setColor(new Color(135, 90, 45)); + graphics.fillRect(8, 0, 8, 16); + graphics.dispose(); + return image; + } + + private static BufferedImage weakSeparationFixture() { + var image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(new Color(170, 170, 170)); + graphics.fillRect(0, 0, 16, 16); + graphics.dispose(); + return image; + } + + private static BufferedImage whiteImage(int width, int height) { + var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, width, height); + graphics.dispose(); + return image; + } + + private static String sha256(byte[] value) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (java.security.GeneralSecurityException impossible) { + throw new IOException("SHA-256 is unavailable", impossible); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/BrightfieldStainQc.java b/src/main/java/org/pathlab/forge/evidence/BrightfieldStainQc.java new file mode 100644 index 0000000..4099a00 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/BrightfieldStainQc.java @@ -0,0 +1,47 @@ +package org.pathlab.forge.evidence; + +import java.awt.image.BufferedImage; + +/** Deterministic preflight for bounded brightfield H/DAB measurements. */ +public final class BrightfieldStainQc { + private BrightfieldStainQc() {} + + public static Result inspect(BufferedImage image, boolean controlsValidated) { + if (image == null || image.getWidth() < 1 || image.getHeight() < 1) { + throw new IllegalArgumentException("Brightfield image geometry is invalid"); + } + long pixels = (long) image.getWidth() * image.getHeight(); + var nearWhite = 0L; + var saturated = 0L; + var blueLike = 0L; + var brownLike = 0L; + double channelSpread = 0; + for (var y = 0; y < image.getHeight(); y++) for (var x = 0; x < image.getWidth(); x++) { + var rgb = image.getRGB(x, y); + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + if (red > 245 && green > 245 && blue > 245) nearWhite++; + // White background is expected in brightfield and must not be treated as clipped tissue. + if (red < 3 || green < 3 || blue < 3) saturated++; + if (blue - red >= 20 && blue - green >= 20) blueLike++; + if (red - green >= 12 && green - blue >= 10) brownLike++; + channelSpread += Math.max(red, Math.max(green, blue)) - Math.min(red, Math.min(green, blue)); + } + var background = (double) nearWhite / pixels; + var saturation = (double) saturated / pixels; + var hematoxylin = (double) blueLike / pixels; + var dab = (double) brownLike / pixels; + var separation = Math.min(1, channelSpread / pixels / 64.0); + var reasons = new java.util.ArrayList(); + if (background > 0.97) reasons.add("insufficient_tissue"); + if (saturation > 0.35) reasons.add("excessive_saturation"); + if (separation < 0.03) reasons.add("weak_stain_separation"); + var status = reasons.isEmpty() ? (controlsValidated ? "calibrated" : "relative_only") : "not_evaluable"; + return new Result(status, background, saturation, hematoxylin, dab, separation, java.util.List.copyOf(reasons)); + } + + public record Result(String calibrationStatus, double backgroundFraction, double saturationFraction, + double hematoxylinLikeFraction, double dabLikeFraction, double separationScore, + java.util.List reasons) {} +} diff --git a/src/main/java/org/pathlab/forge/evidence/BrightfieldTileAnalyzer.java b/src/main/java/org/pathlab/forge/evidence/BrightfieldTileAnalyzer.java new file mode 100644 index 0000000..472826a --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/BrightfieldTileAnalyzer.java @@ -0,0 +1,78 @@ +package org.pathlab.forge.evidence; + +import java.awt.image.BufferedImage; +import java.util.Set; + +/** Bounded deterministic hematoxylin-component and DAB descriptor baseline. */ +public final class BrightfieldTileAnalyzer { + private static final Set KNOWN = Set.of("er", "pr", "ki-67", "her2", "pd-l1"); + + private BrightfieldTileAnalyzer() {} + + public static Result analyze(BufferedImage image, String requestedMarker) { + if (image == null || image.getWidth() < 1 || image.getHeight() < 1 + || (long) image.getWidth() * image.getHeight() > 4_194_304) { + throw new IllegalArgumentException("Brightfield image geometry is invalid"); + } + var marker = requestedMarker == null ? "generic" : requestedMarker.toLowerCase(); + if (!KNOWN.contains(marker)) marker = "generic"; + var width = image.getWidth(); + var height = image.getHeight(); + var dabPixels = 0; + double dabOd = 0; + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var rgb = image.getRGB(x, y); + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + if (red - green >= 15 && green - blue >= 15 && red + green + blue < 660) { + dabPixels++; + var luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + dabOd += -Math.log((luminance + 1) / 256.0); + } + } + } + var segmentation = OpticalDensityWatershed.segment(image); + var instances = segmentation.instances(); + return new Result( + marker, + compartment(marker), + instances.size(), + average(instances, OpticalDensityWatershed.Instance::areaPx2), + average(instances, OpticalDensityWatershed.Instance::perimeterPx), + average(instances, OpticalDensityWatershed.Instance::eccentricity), + average(instances, OpticalDensityWatershed.Instance::solidity), + (double) dabPixels / (width * height), + dabPixels == 0 ? 0 : dabOd / dabPixels, + true, + instances); + } + + private static Double average(java.util.List instances, + java.util.function.ToDoubleFunction getter) { + return instances.isEmpty() ? null : instances.stream().mapToDouble(getter).average().orElseThrow(); + } + + private static String compartment(String marker) { + return switch (marker) { + case "er", "pr", "ki-67" -> "nuclear"; + case "her2" -> "membrane"; + case "pd-l1" -> "tumor-immune-region"; + default -> "generic-region"; + }; + } + + public record Result( + String marker, + String compartment, + int cellCount, + Double meanNucleusAreaPx2, + Double meanNucleusPerimeterPx, + Double meanNucleusEccentricity, + Double meanNucleusSolidity, + double dabAreaFraction, + double meanDabOd, + boolean researchEstimate, + java.util.List instances) {} +} diff --git a/src/main/java/org/pathlab/forge/evidence/CapabilityMatrixWriter.java b/src/main/java/org/pathlab/forge/evidence/CapabilityMatrixWriter.java new file mode 100644 index 0000000..189076e --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/CapabilityMatrixWriter.java @@ -0,0 +1,57 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.Map; + +/** Writes the signed terminal distinction between campaign completion and target success. */ +public final class CapabilityMatrixWriter { + public static final String SCHEMA = "pathlab.capability-matrix/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private final Path stateRoot; + + public CapabilityMatrixWriter(Path stateRoot) { + this.stateRoot = stateRoot.toAbsolutePath().normalize(); + } + + public EvidenceBundleWriter.Result write(QualificationRunSnapshot run, Instant generatedAt) + throws IOException { + if (!run.campaignCompleted()) throw new IllegalArgumentException("Capability matrix requires a terminal campaign"); + var root = JSON.createObjectNode(); + root.put("schema", SCHEMA); root.put("campaignId", run.id()); + root.put("campaignManifestSha256", run.manifestSha256()); + root.put("campaignCompleted", true); root.put("campaignTargetMet", run.campaignTargetMet()); + root.put("generatedAt", generatedAt.toString()); + var tracks = root.putArray("tracks"); + for (var track : run.tracks()) { + var item = tracks.addObject(); item.put("trackId", track.id()); + item.put("candidateId", track.candidateId()); item.put("capability", track.capability()); + item.put("scope", track.scope()); item.put("status", track.verdict()); + item.put("activationEligible", "qualified".equals(track.verdict()) + && !"local-benchmark".equals(track.scope())); + if (track.artifactSha256().isBlank()) item.putNull("qualificationReportSha256"); + else item.put("qualificationReportSha256", track.artifactSha256()); + if (track.failureCode().isBlank()) item.putNull("failureCode"); + else item.put("failureCode", track.failureCode()); + } + var manifestSha = EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(root)); + var signer = new EvidenceSigner(stateRoot.resolve("signing")); + var signed = signer.sign(SCHEMA + "\n" + manifestSha); + new TrustedSignerRegistry(stateRoot).pin(signed.keyId(), signed.publicKeyDer(), "qualification"); + var complete = root.deepCopy(); complete.put("manifestSha256", manifestSha); + complete.set("signature", JSON.valueToTree(Map.of( + "algorithm", "Ed25519", "keyId", signed.keyId(), "value", signed.signature()))); + var output = stateRoot.resolve("qualification").resolve(run.id()).resolve("capability-matrix.json"); + Files.createDirectories(output.getParent()); + var partial = output.resolveSibling(output.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), complete); + try { Files.move(partial, output, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(partial, output, StandardCopyOption.REPLACE_EXISTING); } + return new EvidenceBundleWriter.Result(output, manifestSha, signed.keyId()); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/CellInstanceQualificationEvaluator.java b/src/main/java/org/pathlab/forge/evidence/CellInstanceQualificationEvaluator.java new file mode 100644 index 0000000..5cec3d0 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/CellInstanceQualificationEvaluator.java @@ -0,0 +1,354 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.awt.Polygon; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import javax.imageio.ImageIO; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; + +/** Evaluates the deterministic cell-instance fallback against a frozen held-out cohort. */ +public final class CellInstanceQualificationEvaluator { + public static final String SCHEMA = "pathlab.cell-instance-metrics/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set ORGANS = Set.of("lung", "kidney", "breast", "prostate"); + + private CellInstanceQualificationEvaluator() { } + + public static void main(String[] args) throws Exception { + require(args.length == 3, "Usage: "); + var output = Path.of(args[2]).toAbsolutePath().normalize(); + var metrics = evaluate(Path.of(args[0]), args[1]); + Files.createDirectories(output.getParent()); + var partial = output.resolveSibling(output.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), metrics); + try { + Files.move(partial, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + Files.move(partial, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + + public static ObjectNode evaluate(Path manifestPath, String expectedSha256) throws IOException { + var normalizedManifest = manifestPath.toAbsolutePath().normalize(); + require(Files.isRegularFile(normalizedManifest) + && expectedSha256.matches("[a-f0-9]{64}") + && expectedSha256.equals(sha256(normalizedManifest)), + "Cell qualification cohort checksum does not match"); + var cohort = JSON.readTree(normalizedManifest.toFile()); + require("pathlab.cell-qualification-cohort/1".equals(cohort.path("schema").asText()) + && cohort.path("sampleCount").asInt(-1) == cohort.path("samples").size() + && cohort.path("samples").isArray() && cohort.path("samples").size() >= 4, + "Cell qualification cohort contract is invalid"); + var root = normalizedManifest.getParent(); + var started = System.nanoTime(); + long peakHeapBytes = usedHeapBytes(); + var rows = new ArrayList(); + var failures = 0; + var repeatable = true; + var rightsAndIntegrity = true; + var observedOrgans = new java.util.HashSet(); + for (var sample : cohort.path("samples")) { + try { + var organ = requiredText(sample, "organ"); + require(ORGANS.contains(organ) + && "qualification-held-out-test".equals(requiredText(sample, "split")) + && !sample.path("patientOverlapWithTraining").asBoolean(true) + && "CC-BY-NC-SA-4.0".equals(requiredText(sample, "license")) + && "private-research-restricted".equals(requiredText(sample, "permittedUse")), + "Cell qualification sample rights or split is invalid"); + var imagePath = relativePath(root, requiredText(sample, "imagePath")); + var annotationPath = relativePath(root, requiredText(sample, "annotationPath")); + require(requiredText(sample, "imageSha256").equals(sha256(imagePath)) + && requiredText(sample, "annotationSha256").equals(sha256(annotationPath)), + "Cell qualification sample checksum changed"); + var image = ImageIO.read(imagePath.toFile()); + require(image != null && image.getWidth() == sample.path("width").asInt() + && image.getHeight() == sample.path("height").asInt() + && (long) image.getWidth() * image.getHeight() <= 4_194_304, + "Cell qualification image geometry is invalid"); + var truth = groundTruth(annotationPath, image.getWidth(), image.getHeight()); + require(!truth.isEmpty(), "Cell qualification annotation contains no instances"); + var first = OpticalDensityWatershed.segment(image); + var second = OpticalDensityWatershed.segment(image); + repeatable &= first.equals(second); + rows.add(compare(organ, truth, predicted(first.instances(), image.getWidth(), image.getHeight()))); + observedOrgans.add(organ); + peakHeapBytes = Math.max(peakHeapBytes, usedHeapBytes()); + } catch (RuntimeException | IOException failure) { + failures++; + rightsAndIntegrity = false; + } + } + require(!rows.isEmpty(), "No cell qualification region was evaluable"); + var elapsedSeconds = (System.nanoTime() - started) / 1_000_000_000.0; + var gates = cohort.path("gates"); + var macroPq = rows.stream().mapToDouble(SampleMetrics::pq).average().orElseThrow(); + var instanceDice = rows.stream().mapToDouble(SampleMetrics::dice).average().orElseThrow(); + var countError = rows.stream().mapToDouble(SampleMetrics::countError).average().orElseThrow(); + var morphometry = median(rows.stream().map(SampleMetrics::morphometryBias).sorted().toList()); + var failedRegionRate = (double) failures / cohort.path("samples").size(); + var crossTissue = observedOrgans.equals(ORGANS); + var peakHeapMiB = peakHeapBytes / 1_048_576.0; + var resourceCompliant = elapsedSeconds <= 120.0 && peakHeapMiB <= 1024.0; + + var result = JSON.createObjectNode(); + result.put("schema", SCHEMA); + result.put("cohortManifestSha256", expectedSha256); + result.put("sampleCount", cohort.path("samples").size()); + result.put("evaluatedSampleCount", rows.size()); + result.put("macroPq", macroPq); + result.put("instanceDice", instanceDice); + result.put("countError", countError); + result.put("morphometryBias", morphometry); + result.put("failedRegionRate", failedRegionRate); + result.put("deterministicRepeat", repeatable); + result.put("crossTissuePerformance", crossTissue); + result.put("rightsAndIntegrityPassed", rightsAndIntegrity); + result.put("resourceCompliant", resourceCompliant); + result.put("elapsedSeconds", elapsedSeconds); + result.put("peakHeapMiB", peakHeapMiB); + copyGate(gates, result, "minimumMacroPq"); + copyGate(gates, result, "minimumInstanceDice"); + copyGate(gates, result, "maximumCountError"); + copyGate(gates, result, "maximumMorphometryBias"); + copyGate(gates, result, "maximumFailedRegionRate"); + result.put("sourceIntegrity", cohort.path("source").path("integrity").asText()); + result.put("upstreamChecksumAvailable", + cohort.path("source").path("upstreamChecksumAvailable").asBoolean(false)); + var perOrgan = result.putObject("perOrgan"); + for (var organ : ORGANS.stream().sorted().toList()) { + var organRows = rows.stream().filter(row -> organ.equals(row.organ())).toList(); + var item = perOrgan.putObject(organ); + item.put("sampleCount", organRows.size()); + item.put("macroPq", organRows.stream().mapToDouble(SampleMetrics::pq).average().orElse(Double.NaN)); + item.put("instanceDice", organRows.stream().mapToDouble(SampleMetrics::dice).average().orElse(Double.NaN)); + item.put("countError", organRows.stream().mapToDouble(SampleMetrics::countError).average().orElse(Double.NaN)); + } + var reasons = result.putArray("notEvaluableReasons"); + if (!rightsAndIntegrity) reasons.add("CELL_COHORT_RIGHTS_OR_INTEGRITY_FAILED"); + if (!crossTissue) reasons.add("CELL_CROSS_TISSUE_COVERAGE_INCOMPLETE"); + if (!resourceCompliant) reasons.add("CELL_RESOURCE_ENVELOPE_EXCEEDED"); + return result; + } + + private static SampleMetrics compare(String organ, List truth, List predicted) { + var pairs = new ArrayList(); + for (var truthIndex = 0; truthIndex < truth.size(); truthIndex++) { + for (var predictedIndex = 0; predictedIndex < predicted.size(); predictedIndex++) { + var intersection = intersection(truth.get(truthIndex), predicted.get(predictedIndex)); + if (intersection == 0) continue; + var union = truth.get(truthIndex).area() + predicted.get(predictedIndex).area() - intersection; + var iou = (double) intersection / union; + if (iou >= 0.5) pairs.add(new Pair(truthIndex, predictedIndex, intersection, iou)); + } + } + pairs.sort(Comparator.comparingDouble(Pair::iou).reversed() + .thenComparingInt(Pair::truthIndex).thenComparingInt(Pair::predictedIndex)); + var usedTruth = new boolean[truth.size()]; + var usedPredicted = new boolean[predicted.size()]; + var matches = new ArrayList(); + for (var pair : pairs) { + if (!usedTruth[pair.truthIndex()] && !usedPredicted[pair.predictedIndex()]) { + usedTruth[pair.truthIndex()] = true; + usedPredicted[pair.predictedIndex()] = true; + matches.add(pair); + } + } + var falsePositive = predicted.size() - matches.size(); + var falseNegative = truth.size() - matches.size(); + var pqDenominator = matches.size() + 0.5 * falsePositive + 0.5 * falseNegative; + var pq = pqDenominator == 0 ? 0 : matches.stream().mapToDouble(Pair::iou).sum() / pqDenominator; + var diceDenominator = Math.max(truth.size(), predicted.size()); + var dice = diceDenominator == 0 ? 0 : matches.stream().mapToDouble(pair -> { + var truthMask = truth.get(pair.truthIndex()); + var predictedMask = predicted.get(pair.predictedIndex()); + return 2.0 * pair.intersection() / (truthMask.area() + predictedMask.area()); + }).sum() / diceDenominator; + var biases = matches.stream().mapToDouble(pair -> { + var truthMask = truth.get(pair.truthIndex()); + var predictedMask = predicted.get(pair.predictedIndex()); + var areaBias = Math.abs(predictedMask.area() - truthMask.area()) / (double) truthMask.area(); + var perimeterBias = Math.abs(predictedMask.perimeter() - truthMask.perimeter()) + / (double) Math.max(1, truthMask.perimeter()); + return (areaBias + perimeterBias) / 2; + }).sorted().boxed().toList(); + return new SampleMetrics(organ, pq, dice, + Math.abs(predicted.size() - truth.size()) / (double) Math.max(1, truth.size()), + biases.isEmpty() ? 1.0 : median(biases)); + } + + private static List predicted(List instances, int width, int height) { + var result = new ArrayList(); + for (var instance : instances) { + var pixelCount = 0; + for (var index = 0; index < instance.rle().size(); index += 2) { + var start = instance.rle().get(index); + var length = instance.rle().get(index + 1); + require(start >= 0 && length > 0 && start + length <= width * height, + "Predicted instance RLE is invalid"); + pixelCount += length; + } + var pixels = new int[pixelCount]; + var cursor = 0; + for (var index = 0; index < instance.rle().size(); index += 2) { + var start = instance.rle().get(index); + var length = instance.rle().get(index + 1); + for (var offset = 0; offset < length; offset++) pixels[cursor++] = start + offset; + } + result.add(mask(pixels, (int) Math.round(instance.perimeterPx()), width)); + } + return result; + } + + private static List groundTruth(Path annotationPath, int width, int height) throws IOException { + try { + var factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + var document = factory.newDocumentBuilder().parse(annotationPath.toFile()); + var regions = document.getElementsByTagName("Region"); + var result = new ArrayList(); + for (var regionIndex = 0; regionIndex < regions.getLength(); regionIndex++) { + var region = (org.w3c.dom.Element) regions.item(regionIndex); + var vertices = region.getElementsByTagName("Vertex"); + if (vertices.getLength() < 3) continue; + var x = new int[vertices.getLength()]; + var y = new int[vertices.getLength()]; + for (var index = 0; index < vertices.getLength(); index++) { + var vertex = (org.w3c.dom.Element) vertices.item(index); + x[index] = (int) Math.round(Double.parseDouble(vertex.getAttribute("X"))); + y[index] = (int) Math.round(Double.parseDouble(vertex.getAttribute("Y"))); + } + var polygon = new Polygon(x, y, vertices.getLength()); + var bounds = polygon.getBounds().intersection(new java.awt.Rectangle(0, 0, width, height)); + var pixels = new ArrayList(); + for (var py = bounds.y; py < bounds.y + bounds.height; py++) { + for (var px = bounds.x; px < bounds.x + bounds.width; px++) { + if (polygon.contains(px + 0.5, py + 0.5)) pixels.add(py * width + px); + } + } + if (!pixels.isEmpty()) { + var raw = pixels.stream().mapToInt(Integer::intValue).toArray(); + result.add(mask(raw, perimeter(raw, width, height), width)); + } + } + return List.copyOf(result); + } catch (javax.xml.parsers.ParserConfigurationException | org.xml.sax.SAXException + | NumberFormatException invalidXml) { + throw new IllegalArgumentException("Cell qualification annotation is invalid", invalidXml); + } + } + + private static int perimeter(int[] pixels, int width, int height) { + var included = new java.util.HashSet(Math.max(16, pixels.length * 2)); + for (var pixel : pixels) included.add(pixel); + var perimeter = 0; + for (var pixel : pixels) { + var x = pixel % width; + var y = pixel / width; + if (x == 0 || !included.contains(pixel - 1)) perimeter++; + if (x + 1 == width || !included.contains(pixel + 1)) perimeter++; + if (y == 0 || !included.contains(pixel - width)) perimeter++; + if (y + 1 == height || !included.contains(pixel + width)) perimeter++; + } + return perimeter; + } + + private static int intersection(Mask left, Mask right) { + if (left.maxX() < right.minX() || right.maxX() < left.minX() + || left.maxY() < right.minY() || right.maxY() < left.minY()) return 0; + var leftPixels = left.pixels(); + var rightPixels = right.pixels(); + var leftIndex = 0; + var rightIndex = 0; + var count = 0; + while (leftIndex < leftPixels.length && rightIndex < rightPixels.length) { + if (leftPixels[leftIndex] == rightPixels[rightIndex]) { + count++; + leftIndex++; + rightIndex++; + } else if (leftPixels[leftIndex] < rightPixels[rightIndex]) leftIndex++; + else rightIndex++; + } + return count; + } + + private static Mask mask(int[] pixels, int perimeter, int width) { + java.util.Arrays.sort(pixels); + var minX = Integer.MAX_VALUE; + var minY = Integer.MAX_VALUE; + var maxX = -1; + var maxY = -1; + for (var pixel : pixels) { + minX = Math.min(minX, pixel % width); + maxX = Math.max(maxX, pixel % width); + minY = Math.min(minY, pixel / width); + maxY = Math.max(maxY, pixel / width); + } + return new Mask(pixels, pixels.length, perimeter, minX, minY, maxX, maxY); + } + + private static Path relativePath(Path root, String relative) { + require(!Path.of(relative).isAbsolute(), "Cell qualification sample path must be relative"); + var path = root.resolve(relative).normalize(); + require(path.startsWith(root) && Files.isRegularFile(path), "Cell qualification sample path is invalid"); + return path; + } + + private static String requiredText(JsonNode node, String field) { + var value = node.path(field); + require(value.isTextual() && !value.textValue().isBlank(), "Cell qualification field is invalid: " + field); + return value.textValue(); + } + + private static void copyGate(JsonNode source, ObjectNode target, String field) { + require(source.path(field).isNumber(), "Cell qualification gate is missing: " + field); + target.put(field, source.path(field).doubleValue()); + } + + private static double median(List values) { + if (values.isEmpty()) return Double.NaN; + var middle = values.size() / 2; + return values.size() % 2 == 1 ? values.get(middle) : (values.get(middle - 1) + values.get(middle)) / 2; + } + + private static long usedHeapBytes() { + var runtime = Runtime.getRuntime(); + return runtime.totalMemory() - runtime.freeMemory(); + } + + private static String sha256(Path path) throws IOException { + try (var input = Files.newInputStream(path)) { + var digest = MessageDigest.getInstance("SHA-256"); + input.transferTo(new java.security.DigestOutputStream(java.io.OutputStream.nullOutputStream(), digest)); + return HexFormat.of().formatHex(digest.digest()); + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + private record Mask(int[] pixels, int area, int perimeter, int minX, int minY, int maxX, int maxY) { } + private record Pair(int truthIndex, int predictedIndex, int intersection, double iou) { } + private record SampleMetrics(String organ, double pq, double dice, double countError, + double morphometryBias) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/DashboardSessions.java b/src/main/java/org/pathlab/forge/evidence/DashboardSessions.java new file mode 100644 index 0000000..577041e --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/DashboardSessions.java @@ -0,0 +1,52 @@ +package org.pathlab.forge.evidence; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** In-memory, boot-scoped dashboard authentication. Nothing survives a service restart. */ +final class DashboardSessions { + private static final Duration CODE_LIFETIME = Duration.ofSeconds(60); + private static final Duration SESSION_LIFETIME = Duration.ofHours(8); + private final SecureRandom random = new SecureRandom(); + private final Map codes = new HashMap<>(); + private final Map sessions = new HashMap<>(); + + synchronized String create(Instant now) { + prune(now); + var code = random(32); + codes.put(code, now.plus(CODE_LIFETIME)); + return code; + } + + synchronized Optional exchange(String code, Instant now) { + prune(now); + var expiry = codes.remove(code); + if (expiry == null || expiry.isBefore(now)) return Optional.empty(); + var token = random(32); + var session = new Session(token, random(32), now.plus(SESSION_LIFETIME)); + sessions.put(token, session); + return Optional.of(session); + } + + synchronized Optional find(String token, Instant now) { + prune(now); + return Optional.ofNullable(sessions.get(token)); + } + + private void prune(Instant now) { + codes.entrySet().removeIf(entry -> entry.getValue().isBefore(now)); + sessions.entrySet().removeIf(entry -> entry.getValue().expiresAt().isBefore(now)); + } + + private String random(int bytes) { + var value = new byte[bytes]; random.nextBytes(value); + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } + + record Session(String token, String csrfToken, Instant expiresAt) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceArtifactStore.java b/src/main/java/org/pathlab/forge/evidence/EvidenceArtifactStore.java new file mode 100644 index 0000000..95a4837 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceArtifactStore.java @@ -0,0 +1,75 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Comparator; +import java.util.Optional; +import java.util.Set; + +/** Finds a signed terminal artifact bound to one exact slide revision. */ +public final class EvidenceArtifactStore { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set TERMINAL = Set.of("completed", "partial", "abstained"); + + private EvidenceArtifactStore() {} + + static boolean isTrusted(Path stateRoot, Path path, String slideSha256, String revision) { + return matches(stateRoot.toAbsolutePath().normalize(), path.toAbsolutePath().normalize(), + slideSha256, revision); + } + + public static Optional find(Path stateRoot, String slideSha256, String revision) + throws IOException { + var artifacts = stateRoot.toAbsolutePath().normalize().resolve("artifacts"); + if (!Files.isDirectory(artifacts)) return Optional.empty(); + try (var paths = Files.walk(artifacts, 3)) { + return paths.filter(path -> path.getFileName().toString().equals("evidence.json")) + .filter(Files::isRegularFile) + .limit(2_000) + .filter(path -> matches(stateRoot, path, slideSha256, revision)) + .max(Comparator.comparingLong(EvidenceArtifactStore::modified)); + } + } + + private static boolean matches(Path stateRoot, Path path, String sha, String revision) { + try { + var root = JSON.readTree(path.toFile()); + if (!Set.of("pathlab.ai-evidence/1", "pathlab.ai-evidence/2") + .contains(root.path("schema").asText())) return false; + if (!sha.equals(root.path("source").path("slideSha256").asText()) + || !revision.equals(root.path("source").path("revision").asText()) + || !TERMINAL.contains(root.path("status").asText()) + || root.path("qualificationOnly").asBoolean(false) + || !root.path("manifestSha256").asText().matches("[a-f0-9]{64}") + || !"Ed25519".equals(root.path("signature").path("algorithm").asText())) return false; + var unsigned = root.deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) unsigned).remove("manifestSha256"); + ((com.fasterxml.jackson.databind.node.ObjectNode) unsigned).remove("signature"); + var expected = EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(unsigned)); + if (!expected.equals(root.path("manifestSha256").asText())) return false; + var keyId = root.path("signature").path("keyId").asText(); + var key = new TrustedSignerRegistry(stateRoot).find(keyId, "evidence"); + if (key.isEmpty()) return false; + var verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(KeyFactory.getInstance("Ed25519") + .generatePublic(new X509EncodedKeySpec(key.get()))); + verifier.update((root.path("schema").asText() + "\n" + expected) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return verifier.verify(Base64.getUrlDecoder().decode( + root.path("signature").path("value").asText())); + } catch (Exception ignored) { + return false; + } + } + + private static long modified(Path path) { + try { return Files.getLastModifiedTime(path).toMillis(); } + catch (IOException ignored) { return Long.MIN_VALUE; } + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceBundleWriter.java b/src/main/java/org/pathlab/forge/evidence/EvidenceBundleWriter.java new file mode 100644 index 0000000..fbc0db0 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceBundleWriter.java @@ -0,0 +1,117 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.HexFormat; + +/** Canonicalizes, signs, and atomically publishes a pathlab.ai-evidence/1 manifest. */ +public final class EvidenceBundleWriter { + public static final String SCHEMA = "pathlab.ai-evidence/1"; + private static final ObjectMapper JSON = new ObjectMapper() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private final EvidenceSigner signer; + private final TrustedSignerRegistry registry; + + public EvidenceBundleWriter(Path signingRoot) { + signer = new EvidenceSigner(signingRoot); + var normalized = signingRoot.toAbsolutePath().normalize(); + registry = new TrustedSignerRegistry(normalized.getParent()); + } + + public Result write(Path output, ObjectNode unsigned) throws IOException { + require(SCHEMA.equals(unsigned.path("schema").asText()), "Evidence schema is unsupported"); + require(unsigned.path("researchOnly").asBoolean(false), "Evidence must be research-only"); + require(unsigned.path("notDiagnostic").asBoolean(false), "Evidence must be non-diagnostic"); + require(unsigned.path("reviewRequired").asBoolean(false), "Evidence must require review"); + require(unsigned.path("source").path("slideSha256").asText().matches("[a-f0-9]{64}"), + "Evidence source SHA-256 is invalid"); + require(!unsigned.has("manifestSha256") && !unsigned.has("signature"), + "Unsigned evidence must not contain signature fields"); + var canonical = canonicalBytes(unsigned); + var manifestSha = sha256(canonical); + var signed = signer.sign(SCHEMA + "\n" + manifestSha); + registry.pin(signed.keyId(), signed.publicKeyDer(), "evidence"); + var complete = unsigned.deepCopy(); + complete.put("manifestSha256", manifestSha); + complete.set("signature", JSON.valueToTree(java.util.Map.of( + "algorithm", "Ed25519", + "keyId", signed.keyId(), + "publicKeyDer", signed.publicKeyDer(), + "value", signed.signature()))); + var normalized = output.toAbsolutePath().normalize(); + Files.createDirectories(normalized.getParent()); + var partial = normalized.resolveSibling(normalized.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), complete); + try { + Files.move(partial, normalized, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(partial, normalized, StandardCopyOption.REPLACE_EXISTING); + } + return new Result(normalized, manifestSha, signed.keyId()); + } + + public static byte[] canonicalBytes(JsonNode value) throws IOException { + var output = new StringBuilder(); + appendCanonical(value, output); + return output.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private static void appendCanonical(JsonNode value, StringBuilder output) throws IOException { + if (value.isNull()) { + output.append("null"); + } else if (value.isBoolean()) { + output.append(value.booleanValue()); + } else if (value.isTextual()) { + output.append(JSON.writeValueAsString(value.textValue())); + } else if (value.isIntegralNumber()) { + output.append(value.bigIntegerValue()); + } else if (value.isFloatingPointNumber()) { + var decimal = value.decimalValue(); + if (!Double.isFinite(value.doubleValue())) throw new IOException("Evidence number is invalid"); + output.append(decimal.signum() == 0 ? "0" : decimal.stripTrailingZeros().toPlainString()); + } else if (value.isArray()) { + output.append('['); + for (var index = 0; index < value.size(); index++) { + if (index > 0) output.append(','); + appendCanonical(value.get(index), output); + } + output.append(']'); + } else if (value.isObject()) { + output.append('{'); + var names = new java.util.ArrayList(); + value.fieldNames().forEachRemaining(names::add); + names.sort(String::compareTo); + for (var index = 0; index < names.size(); index++) { + if (index > 0) output.append(','); + output.append(JSON.writeValueAsString(names.get(index))).append(':'); + appendCanonical(value.get(names.get(index)), output); + } + output.append('}'); + } else { + throw new IOException("Evidence value is invalid"); + } + } + + public static String sha256(byte[] value) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (Exception error) { + throw new IOException("SHA-256 is unavailable", error); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + public record Result(Path path, String manifestSha256, String keyId) {} +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceBundleWriterV2.java b/src/main/java/org/pathlab/forge/evidence/EvidenceBundleWriterV2.java new file mode 100644 index 0000000..ee52a2c --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceBundleWriterV2.java @@ -0,0 +1,52 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Map; + +/** Canonical signed evidence v2 writer; signer trust remains external to the payload. */ +public final class EvidenceBundleWriterV2 { + public static final String SCHEMA = "pathlab.ai-evidence/2"; + private static final ObjectMapper JSON = new ObjectMapper(); + private final EvidenceSigner signer; + private final TrustedSignerRegistry registry; + + public EvidenceBundleWriterV2(Path stateRoot) { + var normalized = stateRoot.toAbsolutePath().normalize(); + signer = new EvidenceSigner(normalized.resolve("signing")); + registry = new TrustedSignerRegistry(normalized); + } + + public EvidenceBundleWriter.Result write(Path output, ObjectNode unsigned) throws IOException { + require(SCHEMA.equals(unsigned.path("schema").asText()), "Evidence v2 schema is unsupported"); + require(unsigned.path("researchOnly").asBoolean(false) + && unsigned.path("notDiagnostic").asBoolean(false) + && unsigned.path("reviewRequired").asBoolean(false), + "Evidence v2 research boundary is invalid"); + require(unsigned.path("qualificationAttestationSha256").asText().matches("[a-f0-9]{64}"), + "Evidence v2 qualification attestation is required"); + var manifestSha = EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(unsigned)); + var signed = signer.sign(SCHEMA + "\n" + manifestSha); + registry.pin(signed.keyId(), signed.publicKeyDer(), "evidence"); + var complete = unsigned.deepCopy(); + complete.put("manifestSha256", manifestSha); + complete.set("signature", JSON.valueToTree(Map.of( + "algorithm", "Ed25519", "keyId", signed.keyId(), "value", signed.signature()))); + var target = output.toAbsolutePath().normalize(); + Files.createDirectories(target.getParent()); + var partial = target.resolveSibling(target.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), complete); + try { Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING); } + return new EvidenceBundleWriter.Result(target, manifestSha, signed.keyId()); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceExecutionLane.java b/src/main/java/org/pathlab/forge/evidence/EvidenceExecutionLane.java new file mode 100644 index 0000000..6b4cd23 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceExecutionLane.java @@ -0,0 +1,13 @@ +package org.pathlab.forge.evidence; + +/** Execution lanes are assigned from signed pack metadata, never from IPC input. */ +public enum EvidenceExecutionLane { + GPU("gpu"), CPU_IO("cpu_io"); + private final String wire; + EvidenceExecutionLane(String wire) { this.wire = wire; } + public String wire() { return wire; } + public static EvidenceExecutionLane fromWire(String value) { + for (var lane : values()) if (lane.wire.equals(value)) return lane; + throw new IllegalArgumentException("Unknown evidence execution lane"); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceJob.java b/src/main/java/org/pathlab/forge/evidence/EvidenceJob.java new file mode 100644 index 0000000..a6de4a2 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceJob.java @@ -0,0 +1,34 @@ +package org.pathlab.forge.evidence; + +import java.nio.file.Path; +import java.time.Instant; + +public record EvidenceJob( + String id, + Path requestPath, + EvidenceJobState state, + String stage, + double progress, + int retryCount, + boolean cancelRequested, + String leaseOwner, + Instant leaseExpiresAt, + String detail, + Instant createdAt, + Instant updatedAt) { + public EvidenceJob { + if (id == null || !id.matches("[A-Za-z0-9._-]{1,120}")) { + throw new IllegalArgumentException("Evidence job id is invalid"); + } + requestPath = requestPath.toAbsolutePath().normalize(); + if (!Double.isFinite(progress) || progress < 0 || progress > 1) { + throw new IllegalArgumentException("Evidence job progress is invalid"); + } + if (retryCount < 0 || retryCount > 3) { + throw new IllegalArgumentException("Evidence job retry count is invalid"); + } + detail = detail == null ? "" : detail; + stage = stage == null ? "" : stage; + leaseOwner = leaseOwner == null ? "" : leaseOwner; + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceJobProcessor.java b/src/main/java/org/pathlab/forge/evidence/EvidenceJobProcessor.java new file mode 100644 index 0000000..8102b09 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceJobProcessor.java @@ -0,0 +1,791 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Set; +import javax.imageio.ImageIO; + +/** Executes bounded, network-free deterministic Evidence Mentor packs. */ +public final class EvidenceJobProcessor { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Duration LEASE = Duration.ofMinutes(2); + private static final Set REQUIRED_REQUEST_FIELDS = Set.of( + "schema", "sourcePath", "sourceSha256", "slideRevision", "previewPath", + "sourceWidth", "sourceHeight", "packManifest", "stain", "marker"); + private static final Set OPTIONAL_REQUEST_FIELDS = Set.of( + "compartmentSource", "controlsValidated", "markerIdentitySource", + "tileCacheManifest", "tileCacheManifestSha256", "qualificationAttestationSha256", + "qualificationCampaignManifest", "qualificationCohortManifest", + "qualificationCohortManifestSha256", "reviewedRegions"); + private final EvidenceJobQueue queue; + private final Path stateRoot; + + public EvidenceJobProcessor(EvidenceJobQueue queue, Path stateRoot) throws IOException { + this.queue = queue; + this.stateRoot = stateRoot.toAbsolutePath().normalize(); + Files.createDirectories(this.stateRoot.resolve("artifacts")); + } + + /** Resolves the lane from the validated pack manifest; the IPC caller cannot select it. */ + public static EvidenceExecutionLane executionLane(Path requestPath) throws IOException { + return executionPlan(requestPath).lane(); + } + + public static ExecutionPlan executionPlan(Path requestPath) throws IOException { + return executionPlan(requestPath, ""); + } + + public static ExecutionPlan executionPlan(Path requestPath, String jobId) throws IOException { + return executionPlan(requestPath, jobId, null); + } + + public static ExecutionPlan executionPlan(Path requestPath, String jobId, Path stateRoot) throws IOException { + final JsonNode request; + try { + request = JSON.readTree(requestPath.toAbsolutePath().normalize().toFile()); + } catch (JsonProcessingException invalidJson) { + throw new IllegalArgumentException("Evidence job request JSON is invalid", invalidJson); + } + // Legacy v1 submissions were accepted before lane metadata existed and migrate to CPU/I/O. + if (request == null || !request.path("packManifest").isTextual()) return new ExecutionPlan(EvidenceExecutionLane.CPU_IO, ""); + var pack = EvidencePackManifest.load(regularPath(request, "packManifest")); + if (stateRoot == null) pack.requireExecutableForJob(jobId); + else pack.requireExecutableForJob(jobId, new QualifiedPackRegistry(stateRoot), + request.path("qualificationAttestationSha256").asText("")); + return new ExecutionPlan("cuda".equals(pack.runtimeCompatibility().executionProvider()) + ? EvidenceExecutionLane.GPU : EvidenceExecutionLane.CPU_IO, pack.sha256()); + } + + public record ExecutionPlan(EvidenceExecutionLane lane, String packSha256) { } + + static boolean usesExternalModelWorker(EvidencePackManifest pack) { + return pack.runtimeCompatibility().requiresExternalWorker() + && Set.of(EvidencePackManifest.Capability.HE_EVIDENCE, + EvidencePackManifest.Capability.CELL_MORPHOLOGY) + .contains(pack.capability()); + } + + public EvidenceJob process(EvidenceJob job, String workerId, Instant now) throws IOException { + final JsonNode request; + try { + request = JSON.readTree(job.requestPath().toFile()); + } catch (JsonProcessingException invalidJson) { + throw new IllegalArgumentException("Evidence job request JSON is invalid", invalidJson); + } + var requestFields = fieldNames(request); + require(request.isObject() && requestFields.containsAll(REQUIRED_REQUEST_FIELDS) + && java.util.stream.Stream.concat(REQUIRED_REQUEST_FIELDS.stream(), OPTIONAL_REQUEST_FIELDS.stream()) + .collect(java.util.stream.Collectors.toSet()).containsAll(requestFields), + "Evidence job request is invalid"); + require(Set.of("pathlab.evidence-job/1", "pathlab.evidence-job/2").contains(text(request, "schema")), + "Evidence job schema is unsupported"); + require(!request.has("qualificationCampaignManifest") + || job.id().matches("qualification-[a-f0-9]{8,64}"), + "Only qualification jobs may bind a campaign manifest"); + var source = regularPath(request, "sourcePath"); + var preview = regularPath(request, "previewPath"); + var expectedSha = text(request, "sourceSha256"); + require(expectedSha.matches("[a-f0-9]{64}") && expectedSha.equals(sha256(source)), + "Evidence source checksum does not match"); + var pack = EvidencePackManifest.load(regularPath(request, "packManifest")); + pack.requireExecutableForJob(job.id(), new QualifiedPackRegistry(stateRoot), + request.path("qualificationAttestationSha256").asText("")); + var durable = queue.snapshot(job.id()).orElseThrow(); + require(durable.requestSha256().isBlank() || durable.requestSha256().equals(sha256(job.requestPath())), + "Evidence request changed after submission"); + require(durable.packSha256().isBlank() || durable.packSha256().equals(pack.sha256()), + "Evidence pack changed after submission"); + if (durable.checkpointPath() != null) { + require(Files.isRegularFile(durable.checkpointPath()) + && durable.checkpointSha256().equals(sha256(durable.checkpointPath())), + "Evidence checkpoint checksum does not match"); + } + var stain = text(request, "stain"); + pack.requireStain(stain); + var marker = text(request, "marker").toLowerCase(); + if (!pack.markers().contains(marker)) marker = "generic"; + require(pack.capability() != EvidencePackManifest.Capability.IHC_DESCRIPTIVE + || pack.markers().contains(marker), + "Requested IHC marker is unsupported and generic fallback is unavailable"); + var sourceWidth = positiveInt(request, "sourceWidth"); + var sourceHeight = positiveInt(request, "sourceHeight"); + require(request.has("qualificationCohortManifest") + == request.has("qualificationCohortManifestSha256"), + "Qualification cohort path and checksum must be supplied together"); + Path qualificationCohortPath = null; + var qualificationCohortSha = ""; + if (request.has("qualificationCohortManifest")) { + require(request.has("qualificationCampaignManifest"), + "Only campaign jobs may attach a qualification cohort"); + qualificationCohortPath = regularPath(request, "qualificationCohortManifest"); + qualificationCohortSha = text(request, "qualificationCohortManifestSha256"); + require(qualificationCohortSha.matches("[a-f0-9]{64}") + && qualificationCohortSha.equals(sha256(qualificationCohortPath)), + "Qualification cohort checksum does not match"); + require(Set.of(EvidencePackManifest.Capability.HE_EVIDENCE, + EvidencePackManifest.Capability.CELL_MORPHOLOGY).contains(pack.capability()), + "This capability cannot attach a qualification cohort"); + } + if (pack.capability() == EvidencePackManifest.Capability.HE_EVIDENCE) { + var tileManifestPath = regularPath(request, "tileCacheManifest"); + var tileManifestSha = text(request, "tileCacheManifestSha256"); + var tileCache = EvidenceTileCacheManifest.load(tileManifestPath, tileManifestSha); + tileCache.requireSource(expectedSha, text(request, "slideRevision"), + sourceWidth, sourceHeight, pack.tilePixels()); + require(tileCache.sample().bytes() == Files.size(source) + && "private-research".equals(tileCache.sample().permittedUse()), + "H&E source provenance or permitted use is invalid"); + } else { + require(!request.has("tileCacheManifest") && !request.has("tileCacheManifestSha256"), + "Only H&E evidence jobs may provide a tile cache"); + } + checkCancellation(job.id(), workerId, now); + var active = job; + if (active.state() == EvidenceJobState.VALIDATING || active.state() == EvidenceJobState.RUNNING) { + active = queue.checkpoint(job.id(), workerId, EvidenceJobState.RUNNING, + "running", 0.25, "Running offline bounded brightfield analysis", now, LEASE); + } + + final BufferedImage image; + try { + image = ImageIO.read(preview.toFile()); + } catch (javax.imageio.IIOException invalidImage) { + throw new IllegalArgumentException("Evidence preview could not be decoded", invalidImage); + } + require(image != null, "Evidence preview could not be decoded"); + var analysis = BrightfieldTileAnalyzer.analyze(image, marker); + JsonNode cellQualificationMetrics = null; + if (pack.capability() == EvidencePackManifest.Capability.CELL_MORPHOLOGY + && qualificationCohortPath != null && !usesExternalModelWorker(pack)) { + cellQualificationMetrics = CellInstanceQualificationEvaluator.evaluate( + qualificationCohortPath, qualificationCohortSha); + } + JsonNode modelResult = null; + if (usesExternalModelWorker(pack)) { + modelResult = new ExternalModelWorker(stateRoot).execute(pack, job.requestPath(), job.id(), + durable.checkpointPath(), progress -> { + var current = queue.find(job.id()).orElseThrow(); + if (current.cancelRequested()) { + throw new CancellationException(); + } + queue.heartbeat(job.id(), workerId, progress.completedUnits(), progress.totalUnits(), + progress.checkpointPath(), progress.checkpointSha256(), Instant.now(), LEASE); + }); + require("completed".equals(modelResult.path("status").asText()), + "External model worker returned unsupported or not_evaluable"); + if (pack.capability() == EvidencePackManifest.Capability.CELL_MORPHOLOGY) { + cellQualificationMetrics = modelResult.path("qualificationMetrics"); + } + } + var stainQc = BrightfieldStainQc.inspect(image, + request.path("controlsValidated").isBoolean() + && request.path("controlsValidated").booleanValue()); + var tissue = tissueFraction(image); + var focus = focus(image); + var abstentionReasons = new java.util.ArrayList(); + if (tissue < 0.05) abstentionReasons.add("insufficient_tissue"); + if (focus < 0.05) abstentionReasons.add("out_of_focus_or_uninformative_preview"); + abstentionReasons.addAll(stainQc.reasons().stream() + .filter(reason -> !abstentionReasons.contains(reason)).toList()); + var compartmentSource = request.path("compartmentSource").isTextual() + ? request.path("compartmentSource").textValue() : "none"; + require(Set.of("none", "faculty-authored", "faculty-approved", "model-suggested") + .contains(compartmentSource), "Evidence compartment source is invalid"); + var markerIdentitySource = request.path("markerIdentitySource").isTextual() + ? request.path("markerIdentitySource").textValue() : "unknown"; + require(Set.of("unknown", "faculty-authored", "slide-label", "import-metadata") + .contains(markerIdentitySource), "Evidence marker identity source is invalid"); + var reviewedRegions = reviewedRegions(request.path("reviewedRegions"), image, + sourceWidth, sourceHeight); + if (reviewedRegions.isEmpty()) compartmentSource = "none"; + else if (reviewedRegions.stream().allMatch(region -> "faculty-authored".equals(region.reviewSource()))) { + compartmentSource = "faculty-authored"; + } else if (reviewedRegions.stream().anyMatch(region -> "faculty-approved".equals(region.reviewSource()))) { + compartmentSource = "faculty-approved"; + } else compartmentSource = "model-suggested"; + var ihcResult = pack.capability() == EvidencePackManifest.Capability.IHC_DESCRIPTIVE + ? IhcMeasurementAnalyzer.analyze(image, marker, reviewedRegions, + request.path("controlsValidated").asBoolean(false)) : null; + var specialResult = Set.of(EvidencePackManifest.Capability.SPECIAL_STAIN_DESCRIPTIVE, + EvidencePackManifest.Capability.CYTOLOGY_DESCRIPTIVE).contains(pack.capability()) + ? SpecialStainAnalyzer.analyze(image, stain) : null; + var descriptorWarning = ihcResult != null ? ihcResult.abstentionReason() + : specialResult != null ? specialResult.abstentionReason() : null; + var abstained = !abstentionReasons.isEmpty(); + var refiningAt = now.plusSeconds(1); + checkCancellation(job.id(), workerId, refiningAt); + var refining = active; + if (active.state() != EvidenceJobState.PACKAGING) { + refining = queue.checkpoint(active.id(), workerId, EvidenceJobState.REFINING, + "refining", 0.65, "Refining deterministic region descriptors", refiningAt, LEASE); + } + var packagingAt = now.plusSeconds(2); + var packaging = queue.checkpoint(refining.id(), workerId, EvidenceJobState.PACKAGING, + "packaging", 0.9, "Signing immutable evidence bundle", packagingAt, LEASE); + + var artifactRoot = stateRoot.resolve("artifacts").resolve(job.id()).normalize(); + require(artifactRoot.startsWith(stateRoot.resolve("artifacts")), "Evidence artifact path escaped state root"); + new EvidenceQuotaManager(stateRoot).requireReservation(EvidenceQuotaManager.Bucket.EVIDENCE_TEST, + Math.max(1, Files.size(preview))); + Files.createDirectories(artifactRoot); + var qualificationOnly = job.id().matches("qualification-[a-f0-9]{8,64}"); + var evidenceV2 = EvidencePackManifest.SCHEMA_V2.equals(pack.schema()) && !qualificationOnly; + var unsigned = evidenceV2 + ? evidenceV2(request, pack, image, analysis, modelResult, focus, tissue, + abstentionReasons, compartmentSource, ihcResult, specialResult, + reviewedRegions, now) + : evidence(request, pack, image, analysis, stainQc, modelResult, focus, tissue, + abstentionReasons, compartmentSource, markerIdentitySource, descriptorWarning, + ihcResult, specialResult, reviewedRegions, now); + var finalArtifact = artifactRoot.resolve("evidence.json"); + if (evidenceV2) new EvidenceBundleWriterV2(stateRoot).write(finalArtifact, unsigned); + else new EvidenceBundleWriter(stateRoot.resolve("signing")).write(finalArtifact, unsigned); + if (job.id().matches("qualification-[a-f0-9]{8,64}") + && request.path("qualificationCampaignManifest").isTextual()) { + writeQualificationReport(job, request, pack, modelResult, cellQualificationMetrics, + abstained, now.plusMillis(2400)); + } + queue.recordFinalArtifact(job.id(), workerId, sha256(finalArtifact), now.plusMillis(2500)); + var terminal = abstained ? EvidenceJobState.ABSTAINED : EvidenceJobState.COMPLETED; + var detail = abstained + ? "Signed QC evidence abstained from descriptive analysis" + : "Signed evidence is ready for faculty review"; + return queue.checkpoint(packaging.id(), workerId, terminal, + terminal.name().toLowerCase(), 1, detail, + now.plusSeconds(3), LEASE); + } + + private void writeQualificationReport(EvidenceJob job, JsonNode request, EvidencePackManifest pack, + JsonNode modelResult, JsonNode cellQualificationMetrics, boolean abstained, Instant now) + throws IOException { + var campaign = QualificationCampaignManifest.load( + regularPath(request, "qualificationCampaignManifest")); + var normalizedRequest = job.requestPath().toAbsolutePath().normalize(); + var track = campaign.tracks().stream().filter(candidate -> + normalizedRequest.equals(candidate.requestPath()) + || normalizedRequest.equals(candidate.remediationRequestPath())) + .findFirst().orElseThrow(() -> new IllegalArgumentException( + "Qualification request is not bound to the frozen campaign")); + require(track.candidateId().equals(pack.packId()) + && track.capability().equals(pack.capability().wire()) + && track.scope().equals(pack.scope()), + "Qualification request pack identity changed"); + if (track.expectedAttestationPath() == null) return; + var report = JSON.createObjectNode(); + report.put("schema", QualificationReportWriter.SCHEMA); + report.put("campaignId", campaign.campaignId()); + report.put("campaignManifestSha256", campaign.sha256()); + report.put("trackId", track.id()); + report.put("candidateId", track.candidateId()); + report.put("capability", track.capability()); + report.put("scope", track.scope()); + report.put("packManifestSha256", pack.sha256()); + report.put("protocolSha256", track.protocolSha256()); + report.put("generatedAt", now.toString()); + report.put("researchOnly", true); + report.put("notDiagnostic", true); + var heldOutRequired = switch (pack.capability()) { + case CELL_MORPHOLOGY, IHC_DESCRIPTIVE, SPECIAL_STAIN_DESCRIPTIVE, + CYTOLOGY_DESCRIPTIVE, HE_EVIDENCE, GROUNDED_TUTOR, ATLAS_DISTILLATION -> true; + }; + var retrieval = modelResult == null ? null : modelResult.path("qualificationMetrics"); + var hasRetrieval = retrieval != null && retrieval.isObject() + && "pathlab.he-retrieval-metrics/1".equals(retrieval.path("schema").asText()); + var hasCellMetrics = cellQualificationMetrics != null && cellQualificationMetrics.isObject() + && CellInstanceQualificationEvaluator.SCHEMA.equals( + cellQualificationMetrics.path("schema").asText()); + var repeatable = hasRetrieval && retrieval.path("exactRankingRepeatability").asBoolean(false); + var recallPass = hasRetrieval && finiteAtLeast(retrieval, "macroRecallAt5Improvement", + retrieval.path("minimumMacroRecallAt5Improvement").asDouble(Double.POSITIVE_INFINITY)); + var ndcgPass = hasRetrieval && finiteAtLeast(retrieval, "macroNdcgAt10Improvement", + retrieval.path("minimumMacroNdcgAt10Improvement").asDouble(Double.POSITIVE_INFINITY)); + var oodPass = hasRetrieval && finiteAtLeast(retrieval, "oodAuRoc", + retrieval.path("minimumOodAuRoc").asDouble(Double.POSITIVE_INFINITY)); + var fullCohort = hasRetrieval && "ready".equals(retrieval.path("cohortStatus").asText()); + var cellPqPass = hasCellMetrics && finiteAtLeast(cellQualificationMetrics, "macroPq", + cellQualificationMetrics.path("minimumMacroPq").asDouble(Double.POSITIVE_INFINITY)); + var cellDicePass = hasCellMetrics && finiteAtLeast(cellQualificationMetrics, "instanceDice", + cellQualificationMetrics.path("minimumInstanceDice").asDouble(Double.POSITIVE_INFINITY)); + var cellCountPass = hasCellMetrics && finiteAtMost(cellQualificationMetrics, "countError", + cellQualificationMetrics.path("maximumCountError").asDouble(Double.NEGATIVE_INFINITY)); + var cellMorphometryPass = hasCellMetrics && finiteAtMost(cellQualificationMetrics, "morphometryBias", + cellQualificationMetrics.path("maximumMorphometryBias").asDouble(Double.NEGATIVE_INFINITY)); + var cellFailurePass = hasCellMetrics && finiteAtMost(cellQualificationMetrics, "failedRegionRate", + cellQualificationMetrics.path("maximumFailedRegionRate").asDouble(Double.NEGATIVE_INFINITY)); + var cellRepeatable = hasCellMetrics && cellQualificationMetrics.path("deterministicRepeat").asBoolean(false); + var cellCoverage = hasCellMetrics && cellQualificationMetrics.path("crossTissuePerformance").asBoolean(false); + var cellIntegrity = hasCellMetrics && cellQualificationMetrics.path("rightsAndIntegrityPassed").asBoolean(false); + var cellResources = hasCellMetrics && cellQualificationMetrics.path("resourceCompliant").asBoolean(false); + var cellQualified = hasCellMetrics && cellPqPass && cellDicePass && cellCountPass + && cellMorphometryPass && cellFailurePass && cellRepeatable && cellCoverage + && cellIntegrity && cellResources; + var qualified = !abstained && (pack.capability() == EvidencePackManifest.Capability.CELL_MORPHOLOGY + ? cellQualified : fullCohort && repeatable && recallPass && ndcgPass && oodPass); + report.put("status", abstained ? "not_evaluable" : qualified ? "qualified" : "experimental"); + var checks = report.putArray("checks"); + checks.addObject().put("id", "offline-bounded-execution").put("outcome", + abstained ? "not_evaluable" : hasCellMetrics && !cellResources ? "fail" : "pass") + .put("detail", hasCellMetrics && !cellResources + ? "Candidate exceeded its declared time or memory envelope" + : "Candidate executed without network access inside its declared resource envelope"); + if (hasCellMetrics) { + checks.addObject().put("id", "cell-cohort-integrity-and-rights") + .put("outcome", cellIntegrity ? "pass" : "fail") + .put("detail", "Restricted held-out samples passed exact local checksums and patient overlap audit") + .put("cohortManifestSha256", cellQualificationMetrics.path("cohortManifestSha256").asText()) + .put("sampleCount", cellQualificationMetrics.path("sampleCount").asInt()) + .put("upstreamChecksumAvailable", + cellQualificationMetrics.path("upstreamChecksumAvailable").asBoolean(false)); + checks.addObject().put("id", "cell-instance-accuracy") + .put("outcome", cellPqPass && cellDicePass ? "pass" : "fail") + .put("macroPq", cellQualificationMetrics.path("macroPq").asDouble()) + .put("instanceDice", cellQualificationMetrics.path("instanceDice").asDouble()); + checks.addObject().put("id", "cell-count-and-morphometry") + .put("outcome", cellCountPass && cellMorphometryPass ? "pass" : "fail") + .put("countError", cellQualificationMetrics.path("countError").asDouble()) + .put("morphometryBias", cellQualificationMetrics.path("morphometryBias").asDouble()); + checks.addObject().put("id", "cell-failure-rate") + .put("outcome", cellFailurePass ? "pass" : "fail") + .put("failedRegionRate", cellQualificationMetrics.path("failedRegionRate").asDouble()); + checks.addObject().put("id", "cell-determinism-cross-tissue-resources") + .put("outcome", cellRepeatable && cellCoverage && cellResources ? "pass" : "fail") + .put("deterministicRepeat", cellRepeatable) + .put("crossTissuePerformance", cellCoverage) + .put("resourceCompliant", cellResources); + checks.addObject().put("id", "preregistered-held-out-gates") + .put("outcome", qualified ? "pass" : "fail") + .put("detail", qualified ? "Every frozen cell-instance gate passed" + : "One or more frozen cell-instance gates failed"); + } else if (hasRetrieval) { + checks.addObject().put("id", "cohort-integrity-and-rights").put("outcome", "pass") + .put("detail", "Every cohort tile and provenance record passed checksum and permitted-use validation") + .put("cohortManifestSha256", retrieval.path("cohortManifestSha256").asText()) + .put("sampleCount", retrieval.path("sampleCount").asInt()); + checks.addObject().put("id", "retrieval-baseline-comparison") + .put("outcome", recallPass && ndcgPass ? "pass" : "fail") + .put("detail", "DINOv2 and color-histogram metrics used identical frozen tiles") + .put("macroRecallAt5Improvement", retrieval.path("macroRecallAt5Improvement").asDouble()) + .put("macroNdcgAt10Improvement", retrieval.path("macroNdcgAt10Improvement").asDouble()); + checks.addObject().put("id", "exact-ranking-repeatability") + .put("outcome", repeatable ? "pass" : "fail") + .put("detail", "Two independent deterministic inference passes produced exact query rankings"); + checks.addObject().put("id", "ood-auroc") + .put("outcome", oodPass ? "pass" : "not_evaluable") + .put("detail", oodPass ? "Frozen OOD threshold passed" : "Frozen OOD fixtures are unavailable"); + checks.addObject().put("id", "preregistered-held-out-gates") + .put("outcome", qualified ? "pass" : "not_evaluable") + .put("detail", qualified ? "Every frozen H&E gate passed" + : "Cross-tissue patient/source-held-out coverage remains incomplete"); + } else { + checks.addObject().put("id", "preregistered-held-out-gates").put("outcome", "not_evaluable") + .put("detail", "Required rights-cleared patient/source-held-out fixtures are not attached to this request"); + } + var reasons = report.putArray("reasons"); + if (abstained) reasons.add("INPUT_QC_FAILED"); + else if (qualified) { + // A qualified report has no unresolved reason codes. + } else if (hasCellMetrics) { + cellQualificationMetrics.path("notEvaluableReasons") + .forEach(reason -> reasons.add(reason.asText())); + if (!cellPqPass || !cellDicePass) reasons.add("CELL_INSTANCE_ACCURACY_GATE_FAILED"); + if (!cellCountPass) reasons.add("CELL_COUNT_ERROR_GATE_FAILED"); + if (!cellMorphometryPass) reasons.add("CELL_MORPHOMETRY_GATE_FAILED"); + if (!cellFailurePass) reasons.add("CELL_FAILED_REGION_GATE_FAILED"); + if (!cellRepeatable) reasons.add("CELL_DETERMINISM_GATE_FAILED"); + if (!cellCoverage) reasons.add("CELL_CROSS_TISSUE_GATE_FAILED"); + if (!cellResources) reasons.add("CELL_RESOURCE_GATE_FAILED"); + } else if (hasRetrieval) { + retrieval.path("notEvaluableReasons").forEach(reason -> reasons.add(reason.asText())); + if (!recallPass || !ndcgPass) reasons.add("RETRIEVAL_BASELINE_GATE_FAILED"); + if (!repeatable) reasons.add("RANKING_REPEATABILITY_FAILED"); + if (!oodPass) reasons.add("OOD_QUALIFICATION_PENDING"); + } else reasons.add("HELD_OUT_QUALIFICATION_PENDING"); + new QualificationReportWriter(stateRoot).write(track.expectedAttestationPath(), report); + } + + private static boolean finiteAtLeast(JsonNode node, String field, double minimum) { + var value = node.path(field); + return value.isNumber() && Double.isFinite(value.doubleValue()) + && Double.isFinite(minimum) && value.doubleValue() >= minimum; + } + + private static boolean finiteAtMost(JsonNode node, String field, double maximum) { + var value = node.path(field); + return value.isNumber() && Double.isFinite(value.doubleValue()) + && Double.isFinite(maximum) && value.doubleValue() <= maximum; + } + + private static ObjectNode evidence( + JsonNode request, + EvidencePackManifest pack, + BufferedImage image, + BrightfieldTileAnalyzer.Result analysis, + BrightfieldStainQc.Result stainQc, + JsonNode modelResult, + double focus, + double tissue, + java.util.List abstentionReasons, + String compartmentSource, + String markerIdentitySource, + String descriptorWarning, + IhcMeasurementAnalyzer.Result ihcResult, + SpecialStainAnalyzer.Result specialResult, + java.util.List reviewedRegions, + Instant now) { + var root = JSON.createObjectNode(); + root.put("schema", EvidenceBundleWriter.SCHEMA); + root.put("bundleId", "evidence-" + java.util.UUID.randomUUID()); + root.set("source", JSON.valueToTree(java.util.Map.of( + "slideSha256", text(request, "sourceSha256"), + "revision", text(request, "slideRevision")))); + root.set("pack", JSON.valueToTree(java.util.Map.of( + "id", pack.packId(), + "version", pack.version(), + "manifestSha256", pack.sha256(), + "preprocessing", pack.preprocessingId(), + "artifacts", pack.artifacts().stream().map(EvidencePackManifest.Artifact::sha256).toList(), + "allowedUse", pack.allowedUse(), + "acceptanceOnly", pack.acceptanceOnly(), + "validationStatus", pack.validationStatus().wire()))); + root.put("status", abstentionReasons.isEmpty() ? "completed" : "abstained"); + root.put("researchOnly", true); + root.put("notDiagnostic", true); + root.put("reviewRequired", true); + if (request.path("qualificationCampaignManifest").isTextual()) { + root.put("qualificationOnly", true); + } + var sourceWidth = positiveInt(request, "sourceWidth"); + var sourceHeight = positiveInt(request, "sourceHeight"); + root.set("coordinates", JSON.valueToTree(java.util.Map.of( + "space", "source-pixel", "originX", 0, "originY", 0, + "scaleX", (double) sourceWidth / image.getWidth(), + "scaleY", (double) sourceHeight / image.getHeight()))); + var regions = new java.util.ArrayList>(); + if (modelResult == null) { + regions.add(java.util.Map.of( + "id", "region-1", "stage", "refined", "kind", "support", + "x", 0, "y", 0, "width", sourceWidth, "height", sourceHeight, + "score", Math.max(0, Math.min(1, 1 - analysis.dabAreaFraction())))); + for (var region : reviewedRegions) { + regions.add(java.util.Map.of( + "id", region.id(), "stage", "refined", + "kind", region.kind() + "-compartment", "reviewStatus", region.reviewSource(), + "x", (int) Math.round(region.x() * (double) sourceWidth / image.getWidth()), + "y", (int) Math.round(region.y() * (double) sourceHeight / image.getHeight()), + "width", (int) Math.round(region.width() * (double) sourceWidth / image.getWidth()), + "height", (int) Math.round(region.height() * (double) sourceHeight / image.getHeight()), + "score", 1.0)); + } + root.set("evidence", JSON.valueToTree(regions)); + } else root.set("evidence", modelResult.path("regions").deepCopy()); + var aggregate = new java.util.LinkedHashMap(); + aggregate.put("regionId", "region-1"); + aggregate.put("algorithm", "od-watershed"); + aggregate.put("count", analysis.cellCount()); + aggregate.put("densityPerMm2", null); + aggregate.put("meanNucleusAreaPx2", analysis.meanNucleusAreaPx2()); + aggregate.put("meanNucleusPerimeterPx", analysis.meanNucleusPerimeterPx()); + aggregate.put("meanNucleusEccentricity", analysis.meanNucleusEccentricity()); + aggregate.put("meanNucleusSolidity", analysis.meanNucleusSolidity()); + aggregate.put("uncertainty", 1 - Math.min(focus, tissue)); + root.set("cellAggregates", JSON.valueToTree( + pack.capability() == EvidencePackManifest.Capability.HE_EVIDENCE + ? java.util.List.of() : java.util.List.of(aggregate))); + var ihc = new java.util.ArrayList>(); + if (ihcResult != null) { + var descriptor = new java.util.LinkedHashMap(); + descriptor.put("regionId", "region-1"); + descriptor.put("markerId", analysis.marker()); + descriptor.put("marker", analysis.marker()); + descriptor.put("markerIdentitySource", markerIdentitySource); + descriptor.put("analysisMode", ihcResult.analysisMode()); + descriptor.put("cellMaskSource", "od-watershed"); + descriptor.put("compartmentSource", compartmentSource); + descriptor.put("calibrationStatus", stainQc.calibrationStatus()); + descriptor.put("compartment", ihcResult.compartment()); + descriptor.put("measurements", ihcResult.measurements()); + descriptor.put("qc", ihcResult.qc()); + descriptor.put("uncertainty", Math.max(1 - Math.min(focus, tissue), ihcResult.uncertainty())); + descriptor.put("abstentionReason", ihcResult.abstentionReason()); + descriptor.put("researchEstimate", true); + ihc.add(descriptor); + } + root.set("ihcDescriptors", JSON.valueToTree(ihc)); + var special = new java.util.ArrayList>(); + if (specialResult != null) { + var descriptor = new java.util.LinkedHashMap(); + descriptor.put("regionId", "region-1"); + descriptor.put("stainId", specialResult.stainId()); + descriptor.put("analysisMode", specialResult.analysisMode()); + descriptor.put("cellMaskSource", pack.capability() == EvidencePackManifest.Capability.CYTOLOGY_DESCRIPTIVE + ? "od-watershed" : "none"); + descriptor.put("measurements", specialResult.measurements()); + descriptor.put("qc", specialResult.qc()); + descriptor.put("uncertainty", specialResult.uncertainty()); + descriptor.put("abstentionReason", specialResult.abstentionReason()); + descriptor.put("researchEstimate", true); + special.add(descriptor); + } + root.set(pack.capability() == EvidencePackManifest.Capability.CYTOLOGY_DESCRIPTIVE + ? "cytologyDescriptors" : "specialStainDescriptors", JSON.valueToTree(special)); + root.set("citations", JSON.createArrayNode()); + var qc = new java.util.LinkedHashMap(); + qc.put("focus", focus); + qc.put("tissueFraction", tissue); + qc.put("uncertainty", 1 - Math.min(focus, tissue)); + qc.put("abstentionReasons", abstentionReasons); + qc.put("calibrationStatus", stainQc.calibrationStatus()); + qc.put("backgroundFraction", stainQc.backgroundFraction()); + qc.put("saturationFraction", stainQc.saturationFraction()); + qc.put("stainSeparation", stainQc.separationScore()); + qc.put("warnings", descriptorWarning == null ? java.util.List.of() : java.util.List.of(descriptorWarning)); + root.set("qc", JSON.valueToTree(qc)); + root.set("provenance", JSON.valueToTree(java.util.Map.of( + "createdAt", now.toString(), "codeRevision", "pathlab-forge-evidence-mentor-v1", + "offlineAnalysis", true))); + return root; + } + + private static java.util.List reviewedRegions( + JsonNode node, BufferedImage image, int sourceWidth, int sourceHeight) { + if (node.isMissingNode() || node.isNull()) return java.util.List.of(); + require(node.isArray() && node.size() <= 64, "Reviewed regions are invalid"); + var result = new java.util.ArrayList(); + for (var item : node) { + require(fieldNames(item).equals(Set.of("id", "kind", "reviewSource", "x", "y", "width", "height")), + "Reviewed region fields are invalid"); + var x = boundedCoordinate(item, "x", sourceWidth - 1); + var y = boundedCoordinate(item, "y", sourceHeight - 1); + var width = boundedCoordinate(item, "width", sourceWidth - x); + var height = boundedCoordinate(item, "height", sourceHeight - y); + require(width > 0 && height > 0, "Reviewed region geometry is invalid"); + result.add(new IhcMeasurementAnalyzer.ReviewedRegion( + text(item, "id"), text(item, "kind"), text(item, "reviewSource"), + (int) Math.floor(x * (double) image.getWidth() / sourceWidth), + (int) Math.floor(y * (double) image.getHeight() / sourceHeight), + Math.max(1, (int) Math.ceil(width * (double) image.getWidth() / sourceWidth)), + Math.max(1, (int) Math.ceil(height * (double) image.getHeight() / sourceHeight)))); + } + return java.util.List.copyOf(result); + } + + private static ObjectNode evidenceV2( + JsonNode request, EvidencePackManifest pack, BufferedImage image, + BrightfieldTileAnalyzer.Result analysis, JsonNode modelResult, double focus, double tissue, + java.util.List abstentionReasons, String compartmentSource, + IhcMeasurementAnalyzer.Result ihcResult, SpecialStainAnalyzer.Result specialResult, + java.util.List reviewedRegions, Instant now) { + var sourceWidth = positiveInt(request, "sourceWidth"); + var sourceHeight = positiveInt(request, "sourceHeight"); + var root = JSON.createObjectNode(); + root.put("schema", EvidenceBundleWriterV2.SCHEMA); + root.put("bundleId", "evidence-" + java.util.UUID.randomUUID()); + root.set("source", JSON.valueToTree(java.util.Map.of( + "slideSha256", text(request, "sourceSha256"), "revision", text(request, "slideRevision"), + "width", sourceWidth, "height", sourceHeight))); + root.set("pack", JSON.valueToTree(java.util.Map.of( + "id", pack.packId(), "version", pack.version(), "manifestSha256", pack.sha256(), + "capability", pack.capability().wire(), "scope", pack.scope(), + "preprocessing", pack.preprocessingId(), + "artifacts", pack.artifacts().stream().map(EvidencePackManifest.Artifact::sha256).toList()))); + root.put("qualificationAttestationSha256", text(request, "qualificationAttestationSha256")); + root.put("status", abstentionReasons.isEmpty() ? "completed" : "abstained"); + root.put("researchOnly", true); + root.put("notDiagnostic", true); + root.put("reviewRequired", true); + root.set("coordinates", JSON.valueToTree(java.util.Map.of( + "space", "source-pixel", "originX", 0, "originY", 0, + "scaleX", (double) sourceWidth / image.getWidth(), + "scaleY", (double) sourceHeight / image.getHeight()))); + var regions = root.putArray("regions"); + if (modelResult != null && modelResult.path("regions").isArray()) { + for (var candidate : modelResult.path("regions")) { + var region = regions.addObject(); + region.put("id", candidate.path("id").asText("region-" + regions.size())); + region.put("stage", candidate.path("stage").asText("refined")); + region.put("kind", candidate.path("kind").asText("support")); + region.put("reviewStatus", "unreviewed"); + for (var field : java.util.List.of("x", "y", "width", "height", "score")) { + region.put(field, candidate.path(field).asDouble()); + } + } + } + if (regions.isEmpty()) { + var region = regions.addObject(); + region.put("id", "region-1"); region.put("stage", "refined"); region.put("kind", "analysis"); + region.put("reviewStatus", "unreviewed"); region.put("x", 0); region.put("y", 0); + region.put("width", sourceWidth); region.put("height", sourceHeight); region.put("score", 1.0); + } + for (var reviewed : reviewedRegions) { + var region = regions.addObject(); + region.put("id", reviewed.id()); region.put("stage", "refined"); + region.put("kind", "analysis".equals(reviewed.kind()) + ? "analysis" : reviewed.kind() + "-compartment"); + region.put("reviewStatus", reviewed.reviewSource()); + region.put("x", reviewed.x() * (double) sourceWidth / image.getWidth()); + region.put("y", reviewed.y() * (double) sourceHeight / image.getHeight()); + region.put("width", reviewed.width() * (double) sourceWidth / image.getWidth()); + region.put("height", reviewed.height() * (double) sourceHeight / image.getHeight()); + region.put("score", 1.0); + } + var includeCells = pack.capability() != EvidencePackManifest.Capability.HE_EVIDENCE + && pack.capability() != EvidencePackManifest.Capability.SPECIAL_STAIN_DESCRIPTIVE; + var instances = root.putArray("cellInstances"); + if (includeCells) { + var index = 0; + for (var cell : analysis.instances()) { + var item = instances.addObject(); item.put("id", "cell-" + (++index)); + item.put("regionId", "region-1"); item.put("maskEncoding", "rle"); + item.set("mask", JSON.valueToTree(cell.rle())); item.put("areaPx2", cell.areaPx2()); + item.put("perimeterPx", cell.perimeterPx()); item.put("eccentricity", cell.eccentricity()); + item.put("solidity", cell.solidity()); item.put("meanIntensity", cell.meanIntensity()); + item.put("uncertainty", Math.max(0, 1 - focus)); item.put("category", "unclassified"); + } + } + var aggregates = root.putArray("cellAggregates"); + if (includeCells) { + var aggregate = aggregates.addObject(); aggregate.put("regionId", "region-1"); + aggregate.put("algorithm", "od-watershed"); aggregate.put("count", analysis.cellCount()); + aggregate.putNull("densityPerMm2"); + aggregate.set("distributions", JSON.valueToTree(java.util.Map.of( + "meanAreaPx2", analysis.meanNucleusAreaPx2() == null ? 0 : analysis.meanNucleusAreaPx2(), + "meanPerimeterPx", analysis.meanNucleusPerimeterPx() == null ? 0 : analysis.meanNucleusPerimeterPx(), + "meanEccentricity", analysis.meanNucleusEccentricity() == null ? 0 : analysis.meanNucleusEccentricity(), + "meanSolidity", analysis.meanNucleusSolidity() == null ? 0 : analysis.meanNucleusSolidity()))); + aggregate.put("uncertainty", 1 - Math.min(focus, tissue)); + aggregate.set("qc", JSON.valueToTree(abstentionReasons)); + } + var ihc = root.putArray("ihcDescriptors"); + if (ihcResult != null) addStainDescriptor(ihc, "ihc_dab", ihcResult.markerId(), + ihcResult.analysisMode(), "od-watershed", compartmentSource, + ihcResult.calibrationStatus(), ihcResult.measurements(), ihcResult.qc(), + ihcResult.uncertainty(), ihcResult.abstentionReason()); + var special = root.putArray("specialStainDescriptors"); + var cytology = root.putArray("cytologyDescriptors"); + if (specialResult != null) addStainDescriptor( + pack.capability() == EvidencePackManifest.Capability.CYTOLOGY_DESCRIPTIVE ? cytology : special, + specialResult.stainId(), "none", specialResult.analysisMode(), + pack.capability() == EvidencePackManifest.Capability.CYTOLOGY_DESCRIPTIVE ? "od-watershed" : "none", + "none", "relative_only", specialResult.measurements(), specialResult.qc(), + specialResult.uncertainty(), specialResult.abstentionReason()); + root.set("citations", JSON.createArrayNode()); + root.set("qc", JSON.valueToTree(java.util.Map.of( + "focus", focus, "tissueFraction", tissue, "uncertainty", 1 - Math.min(focus, tissue), + "abstentionReasons", abstentionReasons, + "warnings", java.util.List.of()))); + var provenance = root.putObject("provenance"); + provenance.put("createdAt", now.toString()); + provenance.put("codeRevision", "pathlab-forge-evidence-mentor-v2"); + provenance.put("offlineAnalysis", true); + provenance.putNull("campaignId"); + return root; + } + + private static void addStainDescriptor(com.fasterxml.jackson.databind.node.ArrayNode output, + String stainId, String markerId, String mode, String maskSource, String compartmentSource, + String calibration, java.util.Map measurements, java.util.List qc, + double uncertainty, String abstention) { + var item = output.addObject(); item.put("regionId", "region-1"); item.put("stainId", stainId); + item.put("markerId", markerId); item.put("analysisMode", mode); item.put("cellMaskSource", maskSource); + item.put("compartmentSource", compartmentSource); item.put("calibrationStatus", calibration); + item.set("measurements", JSON.valueToTree(measurements)); item.set("qc", JSON.valueToTree(qc)); + item.put("uncertainty", Math.max(0, Math.min(1, uncertainty))); + if (abstention == null) item.putNull("abstentionReason"); else item.put("abstentionReason", abstention); + item.put("researchEstimate", true); + } + + private static int boundedCoordinate(JsonNode node, String name, int maximum) { + var value = node.path(name); + require(value.isIntegralNumber() && value.canConvertToInt() + && value.intValue() >= 0 && value.intValue() <= maximum, + "Reviewed region geometry is invalid"); + return value.intValue(); + } + + private void checkCancellation(String id, String workerId, Instant now) throws IOException { + var current = queue.find(id).orElseThrow(); + if (current.cancelRequested()) { + queue.checkpoint(id, workerId, EvidenceJobState.CANCELLED, "cancelled", current.progress(), + "Cancelled at a durable checkpoint", now, LEASE); + throw new CancellationException(); + } + } + + private static double tissueFraction(BufferedImage image) { + var tissue = 0; + for (var y = 0; y < image.getHeight(); y++) for (var x = 0; x < image.getWidth(); x++) { + var rgb = image.getRGB(x, y); + if (((rgb >>> 16 & 0xff) + (rgb >>> 8 & 0xff) + (rgb & 0xff)) / 3.0 < 235) tissue++; + } + return (double) tissue / (image.getWidth() * image.getHeight()); + } + + private static double focus(BufferedImage image) { + double gradients = 0; + var samples = 0; + for (var y = 1; y < image.getHeight(); y++) for (var x = 1; x < image.getWidth(); x++) { + var here = image.getRGB(x, y) & 0xff; + gradients += Math.abs(here - (image.getRGB(x - 1, y) & 0xff)); + gradients += Math.abs(here - (image.getRGB(x, y - 1) & 0xff)); + samples += 2; + } + return Math.min(1, gradients / Math.max(1, samples) / 32.0); + } + + private static Path regularPath(JsonNode request, String name) { + var path = Path.of(text(request, name)).toAbsolutePath().normalize(); + require(Files.isRegularFile(path), "Evidence input is unavailable: " + name); + return path; + } + + private static int positiveInt(JsonNode request, String name) { + var value = request.path(name); + require(value.isIntegralNumber() && value.canConvertToInt() && value.intValue() > 0, + "Evidence source geometry is invalid"); + return value.intValue(); + } + + private static Set fieldNames(JsonNode value) { + var result = new java.util.HashSet(); + value.fieldNames().forEachRemaining(result::add); + return result; + } + + private static String text(JsonNode root, String name) { + var value = root.path(name); + require(value.isTextual() && !value.textValue().isBlank(), "Evidence field is invalid: " + name); + return value.textValue(); + } + + private static String sha256(Path path) throws IOException { + try (InputStream input = Files.newInputStream(path)) { + var digest = MessageDigest.getInstance("SHA-256"); + var buffer = new byte[1024 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read); + return HexFormat.of().formatHex(digest.digest()); + } catch (java.security.GeneralSecurityException impossible) { + throw new IOException("SHA-256 is unavailable", impossible); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + public static final class CancellationException extends IOException { + private static final long serialVersionUID = 1L; + public CancellationException() { super("Evidence job was cancelled"); } + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceJobQueue.java b/src/main/java/org/pathlab/forge/evidence/EvidenceJobQueue.java new file mode 100644 index 0000000..1c87475 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceJobQueue.java @@ -0,0 +1,424 @@ +package org.pathlab.forge.evidence; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +/** Durable SQLite WAL queue with lane isolation, renewable leases, and in-place v1 migration. */ +public final class EvidenceJobQueue implements AutoCloseable { + private static final String ACTIVE_STATES = "'VALIDATING','RUNNING','REFINING','PACKAGING'"; + private static final long[] RETRY_DELAYS_SECONDS = {5, 30, 120}; + private final Connection connection; + + public EvidenceJobQueue(Path database) throws IOException { + try { + var normalized = database.toAbsolutePath().normalize(); + Files.createDirectories(normalized.getParent()); + connection = DriverManager.getConnection("jdbc:sqlite:" + normalized); + try (var statement = connection.createStatement()) { + statement.execute("PRAGMA journal_mode=WAL"); + statement.execute("PRAGMA synchronous=FULL"); + statement.execute("PRAGMA busy_timeout=5000"); + statement.execute(""" + CREATE TABLE IF NOT EXISTS evidence_jobs ( + id TEXT PRIMARY KEY, request_path TEXT NOT NULL, state TEXT NOT NULL, + stage TEXT NOT NULL, progress REAL NOT NULL, retry_count INTEGER NOT NULL, + cancel_requested INTEGER NOT NULL, lease_owner TEXT NOT NULL, + lease_expires_at TEXT NOT NULL, detail TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + )"""); + } + migrateV2(); + } catch (SQLException error) { + throw new IOException("Unable to open Evidence Mentor queue", error); + } + } + + private void migrateV2() throws SQLException { + addColumn("lane", "TEXT NOT NULL DEFAULT 'cpu_io'"); + addColumn("completed_units", "INTEGER NOT NULL DEFAULT 0"); + addColumn("total_units", "INTEGER NOT NULL DEFAULT 0"); + addColumn("checkpoint_path", "TEXT NOT NULL DEFAULT ''"); + addColumn("checkpoint_sha256", "TEXT NOT NULL DEFAULT ''"); + addColumn("last_heartbeat", "TEXT NOT NULL DEFAULT '1970-01-01T00:00:00Z'"); + addColumn("throughput", "REAL NOT NULL DEFAULT 0"); + addColumn("eta_seconds", "INTEGER"); + addColumn("checkpoint_count", "INTEGER NOT NULL DEFAULT 0"); + addColumn("next_retry_at", "TEXT NOT NULL DEFAULT '1970-01-01T00:00:00Z'"); + addColumn("failure_class", "TEXT NOT NULL DEFAULT ''"); + addColumn("failure_code", "TEXT NOT NULL DEFAULT ''"); + addColumn("request_sha256", "TEXT NOT NULL DEFAULT ''"); + addColumn("pack_sha256", "TEXT NOT NULL DEFAULT ''"); + addColumn("final_artifact_sha256", "TEXT NOT NULL DEFAULT ''"); + try (var statement = connection.createStatement()) { + statement.execute("CREATE INDEX IF NOT EXISTS ix_evidence_jobs_lane_state ON evidence_jobs(lane,state,next_retry_at,created_at)"); + statement.execute("CREATE TABLE IF NOT EXISTS runner_control (singleton INTEGER PRIMARY KEY CHECK(singleton=1),accepting_jobs INTEGER NOT NULL,updated_at TEXT NOT NULL)"); + statement.execute("INSERT OR IGNORE INTO runner_control VALUES(1,1,'1970-01-01T00:00:00Z')"); + statement.execute("CREATE TABLE IF NOT EXISTS evidence_progress_samples (job_id TEXT NOT NULL,sequence INTEGER PRIMARY KEY AUTOINCREMENT,completed_units INTEGER NOT NULL,recorded_at TEXT NOT NULL)"); + statement.execute("CREATE INDEX IF NOT EXISTS ix_evidence_progress_job_sequence ON evidence_progress_samples(job_id,sequence DESC)"); + } + } + + private void addColumn(String name, String declaration) throws SQLException { + try (var statement = connection.createStatement(); var rows = statement.executeQuery("PRAGMA table_info(evidence_jobs)")) { + while (rows.next()) if (name.equals(rows.getString("name"))) return; + } + try (var statement = connection.createStatement()) { + statement.execute("ALTER TABLE evidence_jobs ADD COLUMN " + name + " " + declaration); + } + } + + public EvidenceJob submit(String id, Path requestPath, Instant now) throws IOException { + return submit(id, requestPath, EvidenceExecutionLane.CPU_IO, now); + } + + public synchronized EvidenceJob submit(String id, Path requestPath, EvidenceExecutionLane lane, Instant now) throws IOException { + return submit(id, requestPath, lane, "", now); + } + + public synchronized EvidenceJob submit(String id, Path requestPath, EvidenceExecutionLane lane, + String packSha256, Instant now) throws IOException { + var normalized = requestPath.toAbsolutePath().normalize(); + if (!Files.isRegularFile(normalized)) throw new IllegalArgumentException("Evidence request file is unavailable"); + var job = new EvidenceJob(id, normalized, EvidenceJobState.QUEUED, "queued", 0, 0, + false, "", Instant.EPOCH, "Queued for autonomous local analysis", now, now); + try (var statement = connection.prepareStatement(""" + INSERT INTO evidence_jobs (id,request_path,state,stage,progress,retry_count,cancel_requested, + lease_owner,lease_expires_at,detail,created_at,updated_at,lane,request_sha256,pack_sha256,next_retry_at,last_heartbeat) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """)) { + bindBase(statement, job); + statement.setString(13, lane.wire()); statement.setString(14, sha256(normalized)); + statement.setString(15, packSha256 == null ? "" : packSha256); + statement.setString(16, Instant.EPOCH.toString()); statement.setString(17, Instant.EPOCH.toString()); + statement.executeUpdate(); + return job; + } catch (SQLException error) { + if (error.getErrorCode() == 19) throw new IllegalArgumentException("Evidence job already exists", error); + throw new IOException("Unable to queue evidence job", error); + } + } + + public synchronized Optional find(String id) throws IOException { + try (var statement = connection.prepareStatement("SELECT * FROM evidence_jobs WHERE id=?")) { + statement.setString(1, id); + try (var rows = statement.executeQuery()) { return rows.next() ? Optional.of(read(rows)) : Optional.empty(); } + } catch (SQLException error) { throw new IOException("Unable to read evidence job", error); } + } + + public synchronized Optional snapshot(String id) throws IOException { + try (var statement = connection.prepareStatement("SELECT * FROM evidence_jobs WHERE id=?")) { + statement.setString(1, id); + try (var rows = statement.executeQuery()) { return rows.next() ? Optional.of(readSnapshot(rows)) : Optional.empty(); } + } catch (SQLException error) { throw new IOException("Unable to read evidence job snapshot", error); } + } + + /** Makes leases from the previous runner boot immediately reclaimable without changing checkpoints. */ + public synchronized int recoverOrphanedActiveJobs(Instant now) throws IOException { + var cancelled = 0; + try (var statement = connection.prepareStatement(""" + UPDATE evidence_jobs SET state='CANCELLED',stage='cancelled',lease_owner='',lease_expires_at=?, + detail=?,updated_at=? WHERE cancel_requested=1 + AND state IN ('VALIDATING','RUNNING','REFINING','PACKAGING') + """)) { + statement.setString(1, Instant.EPOCH.toString()); + statement.setString(2, "Cancelled during runner restart at the last durable checkpoint"); + statement.setString(3, now.toString()); + cancelled = statement.executeUpdate(); + } catch (SQLException error) { + throw new IOException("Unable to recover cancelled Evidence Mentor jobs", error); + } + try (var statement = connection.prepareStatement(""" + UPDATE evidence_jobs SET lease_owner='',lease_expires_at=?,last_heartbeat=?, + detail=?,updated_at=? WHERE cancel_requested=0 + AND state IN ('VALIDATING','RUNNING','REFINING','PACKAGING') + """)) { + statement.setString(1, Instant.EPOCH.toString()); + statement.setString(2, now.toString()); + statement.setString(3, "Recovered after runner restart; verified checkpoint will be revalidated"); + statement.setString(4, now.toString()); + return cancelled + statement.executeUpdate(); + } catch (SQLException error) { + throw new IOException("Unable to recover orphaned Evidence Mentor leases", error); + } + } + + public synchronized List list(EvidenceJobState state, EvidenceExecutionLane lane, int limit) throws IOException { + if (limit < 1 || limit > 200) throw new IllegalArgumentException("Evidence job list limit is invalid"); + var sql = new StringBuilder("SELECT * FROM evidence_jobs WHERE 1=1"); + if (state != null) sql.append(" AND state=?"); + if (lane != null) sql.append(" AND lane=?"); + sql.append(" ORDER BY updated_at DESC,id LIMIT ?"); + try (var statement = connection.prepareStatement(sql.toString())) { + var index = 1; + if (state != null) statement.setString(index++, state.name()); + if (lane != null) statement.setString(index++, lane.wire()); + statement.setInt(index, limit); + try (var rows = statement.executeQuery()) { + var result = new ArrayList(); + while (rows.next()) result.add(readSnapshot(rows)); + return List.copyOf(result); + } + } catch (SQLException error) { throw new IOException("Unable to list evidence jobs", error); } + } + + public Optional claimNext(String workerId, Instant now, Duration lease) throws IOException { + return claimNext(null, workerId, now, lease); + } + + public synchronized Optional claimNext(EvidenceExecutionLane lane, String workerId, Instant now, Duration lease) throws IOException { + validateWorker(workerId, lease); + var accepting = acceptingJobs(); + try { + try (var begin = connection.createStatement()) { begin.execute("BEGIN IMMEDIATE"); } + EvidenceJob selected = null; + var sql = "SELECT * FROM evidence_jobs WHERE cancel_requested=0 AND next_retry_at<=? AND (" + + (accepting ? "state='QUEUED' OR " : "") + "(state IN (" + + ACTIVE_STATES + ") AND lease_expires_at<=?))" + (lane == null ? "" : " AND lane=?") + + " ORDER BY created_at,id LIMIT 1"; + try (var statement = connection.prepareStatement(sql)) { + statement.setString(1, now.toString()); statement.setString(2, now.toString()); + if (lane != null) statement.setString(3, lane.wire()); + try (var rows = statement.executeQuery()) { if (rows.next()) selected = read(rows); } + } + if (selected == null) { commit(); return Optional.empty(); } + var state = selected.state() == EvidenceJobState.QUEUED ? EvidenceJobState.VALIDATING : selected.state(); + var claimed = new EvidenceJob(selected.id(), selected.requestPath(), state, + selected.state() == EvidenceJobState.QUEUED ? "validating" : selected.stage(), selected.progress(), + selected.retryCount(), false, workerId, now.plus(lease), + selected.state() == EvidenceJobState.QUEUED ? "Validating immutable request and pack" : "Recovered after expired worker lease", + selected.createdAt(), now); + update(claimed); + try (var heartbeat = connection.prepareStatement("UPDATE evidence_jobs SET last_heartbeat=? WHERE id=?")) { + heartbeat.setString(1, now.toString()); heartbeat.setString(2, selected.id()); heartbeat.executeUpdate(); + } + commit(); return Optional.of(claimed); + } catch (SQLException error) { rollbackQuietly(); throw new IOException("Unable to claim evidence job", error); } + } + + public synchronized EvidenceJob heartbeat(String id, String workerId, long completed, long total, + Path checkpoint, String checkpointSha256, Instant now, Duration lease) throws IOException { + var current = requireOwned(id, workerId, now); + if (completed < 0 || total < 0 || completed > total) throw new IllegalArgumentException("Worker progress counts are invalid"); + var sha = checkpointSha256 == null ? "" : checkpointSha256; + if (!sha.isEmpty() && !sha.matches("[a-f0-9]{64}")) throw new IllegalArgumentException("Checkpoint checksum is invalid"); + var prior = snapshot(id).orElseThrow(); + if (completed < prior.completedUnits() || total < prior.totalUnits()) throw new IllegalArgumentException("Worker progress is not monotonic"); + var delta = completed - prior.completedUnits(); + var progressRate = delta > 0 ? recordProgressRate(id, completed, total, now) + : new ProgressRate(prior.throughput(), prior.etaSeconds()); + try (var statement = connection.prepareStatement(""" + UPDATE evidence_jobs SET completed_units=?,total_units=?,checkpoint_path=?,checkpoint_sha256=?, + last_heartbeat=?,throughput=?,eta_seconds=?,checkpoint_count=checkpoint_count+?, + lease_expires_at=?,updated_at=? WHERE id=? + """)) { + statement.setLong(1, completed); statement.setLong(2, total); + statement.setString(3, checkpoint == null ? "" : checkpoint.toAbsolutePath().normalize().toString()); + statement.setString(4, sha); statement.setString(5, now.toString()); statement.setDouble(6, progressRate.throughput()); + if (progressRate.etaSeconds() == null) statement.setNull(7, java.sql.Types.INTEGER); else statement.setLong(7, progressRate.etaSeconds()); + statement.setInt(8, delta > 0 ? 1 : 0); statement.setString(9, now.plus(lease).toString()); + statement.setString(10, now.toString()); statement.setString(11, id); statement.executeUpdate(); + } catch (SQLException error) { throw new IOException("Unable to renew evidence lease", error); } + return find(current.id()).orElseThrow(); + } + + private ProgressRate recordProgressRate(String id, long completed, long total, Instant now) throws IOException { + try (var insert = connection.prepareStatement("INSERT INTO evidence_progress_samples(job_id,completed_units,recorded_at) VALUES(?,?,?)")) { + insert.setString(1, id); insert.setLong(2, completed); insert.setString(3, now.toString()); insert.executeUpdate(); + } catch (SQLException error) { throw new IOException("Unable to record evidence throughput sample", error); } + try (var trim = connection.prepareStatement("DELETE FROM evidence_progress_samples WHERE job_id=? AND sequence NOT IN (SELECT sequence FROM evidence_progress_samples WHERE job_id=? ORDER BY sequence DESC LIMIT 5)")) { + trim.setString(1, id); trim.setString(2, id); trim.executeUpdate(); + } catch (SQLException error) { throw new IOException("Unable to bound evidence throughput samples", error); } + try (var statement = connection.prepareStatement("SELECT completed_units,recorded_at FROM evidence_progress_samples WHERE job_id=? ORDER BY sequence")) { + statement.setString(1, id); + try (var rows = statement.executeQuery()) { + long firstUnits = 0, lastUnits = 0; Instant firstAt = null, lastAt = null; int count = 0; + while (rows.next()) { + if (count++ == 0) { firstUnits = rows.getLong(1); firstAt = Instant.parse(rows.getString(2)); } + lastUnits = rows.getLong(1); lastAt = Instant.parse(rows.getString(2)); + } + if (count < 2 || lastUnits <= firstUnits) return new ProgressRate(0, null); + var seconds = Math.max(.001, Duration.between(firstAt, lastAt).toMillis() / 1000.0); + var rate = (lastUnits - firstUnits) / seconds; + return new ProgressRate(rate, Math.max(0, Math.round((total - completed) / rate))); + } + } catch (SQLException error) { throw new IOException("Unable to calculate evidence throughput", error); } + } + + private record ProgressRate(double throughput, Long etaSeconds) { } + + public synchronized EvidenceJob checkpoint(String id, String workerId, EvidenceJobState state, String stage, + double progress, String detail, Instant now, Duration lease) throws IOException { + var current = requireOwned(id, workerId, now); requireTransition(current.state(), state); + var updated = new EvidenceJob(id, current.requestPath(), state, stage, progress, current.retryCount(), + current.cancelRequested(), state.terminal() ? "" : workerId, state.terminal() ? Instant.EPOCH : now.plus(lease), + bounded(detail), current.createdAt(), now); + updateChecked(updated); return updated; + } + + public synchronized EvidenceJob terminate(String id, String workerId, EvidenceJobState state, + String failureClass, String failureCode, String detail, Instant now, Duration lease) throws IOException { + if (!state.terminal()) throw new IllegalArgumentException("Terminal evidence state is required"); + var terminal = checkpoint(id, workerId, state, state.name().toLowerCase(), + find(id).orElseThrow().progress(), detail, now, lease); + executeUpdate("UPDATE evidence_jobs SET failure_class=?,failure_code=? WHERE id=?", + boundedCode(failureClass), boundedCode(failureCode), id); + return terminal; + } + + public synchronized void recordFinalArtifact(String id, String workerId, String sha256, Instant now) throws IOException { + requireOwned(id, workerId, now); + if (sha256 == null || !sha256.matches("[a-f0-9]{64}")) throw new IllegalArgumentException("Final artifact checksum is invalid"); + executeUpdate("UPDATE evidence_jobs SET final_artifact_sha256=?,updated_at=? WHERE id=?", sha256, now.toString(), id); + } + + public synchronized void requestCancel(String id, Instant now) throws IOException { + var current = find(id).orElseThrow(() -> new IllegalArgumentException("Evidence job was not found")); + if (current.state().terminal()) return; + if (current.state() == EvidenceJobState.QUEUED) { + executeUpdate("UPDATE evidence_jobs SET state='CANCELLED',stage='cancelled',cancel_requested=1,detail=?,updated_at=? WHERE id=?", + "Cancelled before execution by the local operator", now.toString(), id); + } else { + executeUpdate("UPDATE evidence_jobs SET cancel_requested=1,detail=?,updated_at=? WHERE id=?", + "Cancellation requested by the local operator", now.toString(), id); + } + } + + public synchronized EvidenceJob fail(String id, String workerId, String failureClass, String failureCode, + String detail, boolean transientIo, Instant now) throws IOException { + var current = find(id).orElseThrow(() -> new IllegalArgumentException("Evidence job was not found")); + if (!workerId.equals(current.leaseOwner())) throw new IllegalStateException("Evidence job lease is not owned"); + var retries = transientIo ? current.retryCount() + 1 : current.retryCount(); + var retry = transientIo && retries <= RETRY_DELAYS_SECONDS.length; + var next = retry ? now.plusSeconds(RETRY_DELAYS_SECONDS[retries - 1]) : Instant.EPOCH; + var state = retry ? EvidenceJobState.QUEUED : EvidenceJobState.FAILED; + try (var statement = connection.prepareStatement(""" + UPDATE evidence_jobs SET state=?,stage=?,retry_count=?,lease_owner='',lease_expires_at=?,next_retry_at=?, + failure_class=?,failure_code=?,detail=?,updated_at=? WHERE id=? + """)) { + statement.setString(1, state.name()); statement.setString(2, retry ? "retrying" : "failed"); + statement.setInt(3, Math.min(3, retries)); statement.setString(4, Instant.EPOCH.toString()); + statement.setString(5, next.toString()); statement.setString(6, boundedCode(failureClass)); + statement.setString(7, boundedCode(failureCode)); statement.setString(8, bounded(detail)); + statement.setString(9, now.toString()); statement.setString(10, id); statement.executeUpdate(); + } catch (SQLException error) { throw new IOException("Unable to record evidence failure", error); } + return find(id).orElseThrow(); + } + + public EvidenceJob retryOrFail(String id, String workerId, String detail, Instant now) throws IOException { + return fail(id, workerId, "transient_io", "IO_TRANSIENT", detail, true, now); + } + + public synchronized EvidenceJob retry(String id, Instant now) throws IOException { + var current = find(id).orElseThrow(() -> new IllegalArgumentException("Evidence job was not found")); + if (current.state() != EvidenceJobState.FAILED && current.state() != EvidenceJobState.CANCELLED) + throw new IllegalStateException("Only failed or cancelled jobs may be retried"); + var snapshot = snapshot(id).orElseThrow(); + var currentRequestSha = sha256(current.requestPath()); + if (!snapshot.requestSha256().isBlank() && !snapshot.requestSha256().equals(currentRequestSha)) + throw new IllegalStateException("Immutable job request changed; retry refused"); + try (var statement = connection.prepareStatement(""" + UPDATE evidence_jobs SET state='QUEUED',stage='queued',cancel_requested=0,lease_owner='',lease_expires_at=?, + next_retry_at=?,failure_class='',failure_code='',request_sha256=?,detail=?,updated_at=? WHERE id=? + """)) { + statement.setString(1, Instant.EPOCH.toString()); statement.setString(2, now.toString()); + statement.setString(3, currentRequestSha); + statement.setString(4, "Requeued by local operator after immutable-input verification"); + statement.setString(5, now.toString()); statement.setString(6, id); statement.executeUpdate(); + } catch (SQLException error) { throw new IOException("Unable to retry evidence job", error); } + return find(id).orElseThrow(); + } + + public synchronized boolean acceptingJobs() throws IOException { + try (var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT accepting_jobs FROM runner_control WHERE singleton=1")) { + return rows.next() && rows.getInt(1) != 0; + } catch (SQLException error) { throw new IOException("Unable to read runner control", error); } + } + + public synchronized void setAcceptingJobs(boolean accepting, Instant now) throws IOException { + try (var statement = connection.prepareStatement("UPDATE runner_control SET accepting_jobs=?,updated_at=? WHERE singleton=1")) { + statement.setInt(1, accepting ? 1 : 0); statement.setString(2, now.toString()); statement.executeUpdate(); + } catch (SQLException error) { throw new IOException("Unable to update runner control", error); } + } + + private EvidenceJob requireOwned(String id, String workerId, Instant now) throws IOException { + var current = find(id).orElseThrow(() -> new IllegalArgumentException("Evidence job was not found")); + if (!workerId.equals(current.leaseOwner()) || current.leaseExpiresAt().isBefore(now)) throw new IllegalStateException("Evidence job lease is not owned by this worker"); + return current; + } + + private void executeUpdate(String sql, String... values) throws IOException { + try (var statement = connection.prepareStatement(sql)) { + for (var i = 0; i < values.length; i++) statement.setString(i + 1, values[i]); + statement.executeUpdate(); + } catch (SQLException error) { throw new IOException("Unable to update evidence queue", error); } + } + + private void updateChecked(EvidenceJob job) throws IOException { try { update(job); } catch (SQLException e) { throw new IOException("Unable to checkpoint evidence job", e); } } + private void update(EvidenceJob job) throws SQLException { + try (var statement = connection.prepareStatement("UPDATE evidence_jobs SET request_path=?,state=?,stage=?,progress=?,retry_count=?,cancel_requested=?,lease_owner=?,lease_expires_at=?,detail=?,created_at=?,updated_at=? WHERE id=?")) { + statement.setString(1, job.requestPath().toString()); statement.setString(2, job.state().name()); statement.setString(3, job.stage()); + statement.setDouble(4, job.progress()); statement.setInt(5, job.retryCount()); statement.setInt(6, job.cancelRequested() ? 1 : 0); + statement.setString(7, job.leaseOwner()); statement.setString(8, job.leaseExpiresAt().toString()); statement.setString(9, job.detail()); + statement.setString(10, job.createdAt().toString()); statement.setString(11, job.updatedAt().toString()); statement.setString(12, job.id()); + if (statement.executeUpdate() != 1) throw new SQLException("Evidence job was not updated"); + } + } + + private static void bindBase(java.sql.PreparedStatement s, EvidenceJob j) throws SQLException { + s.setString(1,j.id()); s.setString(2,j.requestPath().toString()); s.setString(3,j.state().name()); s.setString(4,j.stage()); + s.setDouble(5,j.progress()); s.setInt(6,j.retryCount()); s.setInt(7,j.cancelRequested()?1:0); s.setString(8,j.leaseOwner()); + s.setString(9,j.leaseExpiresAt().toString()); s.setString(10,j.detail()); s.setString(11,j.createdAt().toString()); s.setString(12,j.updatedAt().toString()); + } + + private static EvidenceJob read(ResultSet r) throws SQLException { + return new EvidenceJob(r.getString("id"),Path.of(r.getString("request_path")),EvidenceJobState.valueOf(r.getString("state")),r.getString("stage"), + r.getDouble("progress"),r.getInt("retry_count"),r.getInt("cancel_requested")!=0,r.getString("lease_owner"), + Instant.parse(r.getString("lease_expires_at")),r.getString("detail"),Instant.parse(r.getString("created_at")),Instant.parse(r.getString("updated_at"))); + } + + private static EvidenceJobSnapshot readSnapshot(ResultSet r) throws SQLException { + var checkpoint=r.getString("checkpoint_path"); var eta=r.getObject("eta_seconds"); + return new EvidenceJobSnapshot(r.getString("id"),EvidenceJobState.valueOf(r.getString("state")),r.getString("stage"),r.getDouble("progress"), + EvidenceExecutionLane.fromWire(r.getString("lane")),r.getLong("completed_units"),r.getLong("total_units"),checkpoint.isBlank()?null:Path.of(checkpoint), + r.getString("checkpoint_sha256"),Instant.parse(r.getString("last_heartbeat")),r.getDouble("throughput"),eta==null?null:r.getLong("eta_seconds"), + Instant.parse(r.getString("next_retry_at")),r.getInt("retry_count"),r.getInt("cancel_requested")!=0,r.getString("lease_owner"), + Instant.parse(r.getString("lease_expires_at")),r.getString("failure_class"),r.getString("failure_code"),r.getString("request_sha256"), + r.getString("pack_sha256"),r.getString("final_artifact_sha256"),r.getString("detail"), + Instant.parse(r.getString("created_at")),Instant.parse(r.getString("updated_at"))); + } + + private static void validateWorker(String id, Duration lease) { + if (id==null||!id.matches("[A-Za-z0-9._-]{1,120}")) throw new IllegalArgumentException("Evidence worker id is invalid"); + if (lease.isNegative()||lease.isZero()||lease.compareTo(Duration.ofMinutes(10))>0) throw new IllegalArgumentException("Evidence lease is invalid"); + } + private static void requireTransition(EvidenceJobState from,EvidenceJobState to) { + if(from.terminal()) throw new IllegalStateException("Evidence job is already terminal"); + var allowed=switch(from){ + case QUEUED->java.util.Set.of(EvidenceJobState.VALIDATING,EvidenceJobState.CANCELLED); + case VALIDATING->java.util.Set.of(EvidenceJobState.RUNNING,EvidenceJobState.ABSTAINED,EvidenceJobState.UNSUPPORTED,EvidenceJobState.FAILED,EvidenceJobState.CANCELLED); + case RUNNING->java.util.Set.of(EvidenceJobState.REFINING,EvidenceJobState.PACKAGING,EvidenceJobState.ABSTAINED,EvidenceJobState.UNSUPPORTED,EvidenceJobState.FAILED,EvidenceJobState.CANCELLED); + case REFINING->java.util.Set.of(EvidenceJobState.PACKAGING,EvidenceJobState.ABSTAINED,EvidenceJobState.FAILED,EvidenceJobState.CANCELLED); + case PACKAGING->java.util.Set.of(EvidenceJobState.COMPLETED,EvidenceJobState.ABSTAINED,EvidenceJobState.FAILED,EvidenceJobState.CANCELLED); + default->java.util.Set.of();}; + if(!allowed.contains(to)&&from!=to) throw new IllegalStateException("Invalid evidence job transition: "+from+" -> "+to); + } + private static String bounded(String v){if(v==null||v.isBlank())return "No additional detail";return v.length()<=500?v:v.substring(0,500);} + private static String boundedCode(String v){return v!=null&&v.matches("[A-Za-z0-9._-]{1,80}")?v:"UNCLASSIFIED";} + private static String sha256(Path path)throws IOException{try(InputStream in=Files.newInputStream(path)){var d=MessageDigest.getInstance("SHA-256");var b=new byte[1024*1024];int n;while((n=in.read(b))>=0)d.update(b,0,n);return HexFormat.of().formatHex(d.digest());}catch(java.security.GeneralSecurityException e){throw new IOException("SHA-256 unavailable",e);}} + private void rollbackQuietly(){try(var s=connection.createStatement()){s.execute("ROLLBACK");}catch(SQLException ignored){}} + private void commit()throws SQLException{try(var s=connection.createStatement()){s.execute("COMMIT");}} + @Override public synchronized void close()throws IOException{try{connection.close();}catch(SQLException e){throw new IOException("Unable to close Evidence Mentor queue",e);}} +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceJobSnapshot.java b/src/main/java/org/pathlab/forge/evidence/EvidenceJobSnapshot.java new file mode 100644 index 0000000..bf9f522 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceJobSnapshot.java @@ -0,0 +1,15 @@ +package org.pathlab.forge.evidence; + +import java.nio.file.Path; +import java.time.Instant; + +/** Operations-only durable view. It deliberately excludes slide paths and scientific results. */ +public record EvidenceJobSnapshot( + String id, EvidenceJobState state, String stage, double progress, + EvidenceExecutionLane lane, long completedUnits, long totalUnits, + Path checkpointPath, String checkpointSha256, Instant lastHeartbeat, + double throughput, Long etaSeconds, Instant nextRetryAt, int retryCount, + boolean cancelRequested, String leaseOwner, Instant leaseExpiresAt, + String failureClass, String failureCode, String requestSha256, String packSha256, + String finalArtifactSha256, + String detail, Instant createdAt, Instant updatedAt) { } diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceJobState.java b/src/main/java/org/pathlab/forge/evidence/EvidenceJobState.java new file mode 100644 index 0000000..dfa5e78 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceJobState.java @@ -0,0 +1,18 @@ +package org.pathlab.forge.evidence; + +public enum EvidenceJobState { + QUEUED(false), + VALIDATING(false), + RUNNING(false), + REFINING(false), + PACKAGING(false), + COMPLETED(true), + ABSTAINED(true), + UNSUPPORTED(true), + FAILED(true), + CANCELLED(true); + + private final boolean terminal; + EvidenceJobState(boolean terminal) { this.terminal = terminal; } + public boolean terminal() { return terminal; } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceMentorRunner.java b/src/main/java/org/pathlab/forge/evidence/EvidenceMentorRunner.java new file mode 100644 index 0000000..7dbbcc5 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceMentorRunner.java @@ -0,0 +1,458 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclFileAttributeView; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** Standalone authenticated loopback process for unattended Evidence Mentor jobs. */ +public final class EvidenceMentorRunner implements AutoCloseable { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String VERSION = "2.1.8"; + private static final Duration LEASE = Duration.ofSeconds(45); + private final Path stateRoot; + private final String token; + private final EvidenceJobQueue queue; + private final QualificationRunStore qualificationRuns; + private final HttpServer server; + private final ScheduledExecutorService gpuWorker; + private final ScheduledExecutorService cpuWorker; + private final ScheduledExecutorService campaignWorker; + private final java.util.concurrent.ExecutorService http; + private final DashboardSessions dashboardSessions = new DashboardSessions(); + private final Instant startedAt = Instant.now(); + private final String bootId = UUID.randomUUID().toString(); + private final Path endpointPath; + private final String dashboardHtml; + + private EvidenceMentorRunner(Path stateRoot, int port, String token, boolean workerEnabled) throws IOException { + this.stateRoot = stateRoot.toAbsolutePath().normalize(); + this.token = token; + require(token != null && token.length() >= 32, "Loopback token is too short"); + Files.createDirectories(this.stateRoot); + queue = new EvidenceJobQueue(this.stateRoot.resolve("jobs.sqlite3")); + qualificationRuns = new QualificationRunStore(this.stateRoot.resolve("jobs.sqlite3"), this.stateRoot); + queue.recoverOrphanedActiveJobs(startedAt); + dashboardHtml = readResource("/evidence-dashboard/index.html"); + server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 32); + server.createContext("/", this::handle); + http = Executors.newFixedThreadPool(4, runnable -> daemon(runnable, "pathlab-evidence-ipc", Thread.NORM_PRIORITY)); + server.setExecutor(http); + server.start(); + endpointPath = this.stateRoot.resolve("endpoint.json"); + publishEndpoint(); + gpuWorker = Executors.newSingleThreadScheduledExecutor(r -> daemon(r, "pathlab-evidence-gpu", Thread.NORM_PRIORITY)); + cpuWorker = Executors.newSingleThreadScheduledExecutor(r -> daemon(r, "pathlab-evidence-cpu-io", Thread.MIN_PRIORITY)); + campaignWorker = Executors.newSingleThreadScheduledExecutor( + r -> daemon(r, "pathlab-qualification-coordinator", Thread.MIN_PRIORITY)); + if (workerEnabled) { + gpuWorker.scheduleWithFixedDelay(() -> processOne(EvidenceExecutionLane.GPU), 0, 1, TimeUnit.SECONDS); + cpuWorker.scheduleWithFixedDelay(() -> processOne(EvidenceExecutionLane.CPU_IO), 0, 1, TimeUnit.SECONDS); + campaignWorker.scheduleWithFixedDelay(this::advanceQualificationRuns, 0, 1, TimeUnit.SECONDS); + } + } + + public static EvidenceMentorRunner start(Path stateRoot, int port, String token, boolean worker) throws IOException { + return new EvidenceMentorRunner(stateRoot, port, token, worker); + } + + public URI uri(String path) { return URI.create(origin() + path); } + Optional find(String id) throws IOException { return queue.find(id); } + + private void handle(HttpExchange exchange) throws IOException { + try { + if (!exchange.getRemoteAddress().getAddress().isLoopbackAddress()) returnJson(exchange, 403, Map.of("error", "loopback_required")); + else if (!validHost(exchange)) returnJson(exchange, 403, Map.of("error", "host_mismatch")); + else if (!validOriginWhenPresent(exchange)) returnJson(exchange, 403, Map.of("error", "origin_mismatch")); + else route(exchange); + } catch (SecurityException error) { + returnJson(exchange, 403, Map.of("error", "request_forbidden", "detail", safe(error.getMessage()))); + } catch (IllegalArgumentException error) { + returnJson(exchange, 422, Map.of("error", "invalid_request", "detail", safe(error.getMessage()))); + } catch (IllegalStateException error) { + returnJson(exchange, 409, Map.of("error", "invalid_state", "detail", safe(error.getMessage()))); + } catch (Exception error) { + returnJson(exchange, 500, Map.of("error", "runner_error")); + } finally { exchange.close(); } + } + + private void route(HttpExchange exchange) throws IOException { + var path = exchange.getRequestURI().getPath(); + var method = exchange.getRequestMethod(); + if ("/dashboard/".equals(path) && "GET".equals(method)) { returnHtml(exchange); return; } + if ("/v1/dashboard-session/exchange".equals(path) && "POST".equals(method)) { exchangeDashboardSession(exchange); return; } + if (!authenticated(exchange)) { returnJson(exchange, 401, Map.of("error", "authentication_required")); return; } + if ("/health".equals(path) && "GET".equals(method)) { + returnJson(exchange, 200, Map.of("schema", "pathlab.evidence-runner-status/2", "status", "ready", "bootId", bootId)); return; + } + if ("/v1/status".equals(path) && "GET".equals(method)) { status(exchange); return; } + if ("/v1/jobs".equals(path) && "POST".equals(method)) { submit(exchange); return; } + if ("/v1/jobs".equals(path) && "GET".equals(method)) { listJobs(exchange); return; } + if ("/v1/qualification-runs".equals(path) && "POST".equals(method)) { createQualificationRun(exchange); return; } + if ("/v1/qualification-runs".equals(path) && "GET".equals(method)) { listQualificationRuns(exchange); return; } + if ("/v1/dashboard-sessions".equals(path) && "POST".equals(method)) { + if (!bearerAuthorized(exchange)) { returnJson(exchange, 401, Map.of("error", "bearer_required")); return; } + returnJson(exchange, 201, Map.of("code", dashboardSessions.create(Instant.now()), "expiresInSeconds", 60)); return; + } + if ("/v1/control/pause".equals(path) && "POST".equals(method)) { mutate(exchange); queue.setAcceptingJobs(false, Instant.now()); control(exchange); return; } + if ("/v1/control/resume".equals(path) && "POST".equals(method)) { mutate(exchange); queue.setAcceptingJobs(true, Instant.now()); control(exchange); return; } + if (path.startsWith("/v1/jobs/")) { jobRoute(exchange, path.substring("/v1/jobs/".length())); return; } + if (path.startsWith("/v1/qualification-runs/")) { + qualificationRoute(exchange, path.substring("/v1/qualification-runs/".length())); return; + } + returnJson(exchange, 404, Map.of("error", "not_found")); + } + + private void qualificationRoute(HttpExchange exchange, String suffix) throws IOException { + var parts = suffix.split("/", -1); + var id = parts[0]; + if (!id.matches("[A-Za-z0-9._-]{1,120}")) { + returnJson(exchange, 404, Map.of("error", "qualification_run_not_found")); return; + } + if (parts.length == 1 && "GET".equals(exchange.getRequestMethod())) { + var run = qualificationRuns.snapshot(id); + if (run.isEmpty()) returnJson(exchange, 404, Map.of("error", "qualification_run_not_found")); + else returnJsonCached(exchange, qualificationJson(run.get())); + return; + } + if (parts.length == 1 && "DELETE".equals(exchange.getRequestMethod())) { + mutate(exchange); + qualificationRuns.requestCancel(id, queue, Instant.now()); + returnJson(exchange, 202, qualificationJson(qualificationRuns.snapshot(id).orElseThrow())); + return; + } + if (parts.length == 2 && "resume".equals(parts[1]) && "POST".equals(exchange.getRequestMethod())) { + mutate(exchange); + returnJson(exchange, 202, qualificationJson(qualificationRuns.resume(id, Instant.now()))); + return; + } + if (parts.length == 2 && "attestations".equals(parts[1]) && "POST".equals(exchange.getRequestMethod())) { + mutate(exchange); + var body = jsonBody(exchange, 4096); + require(fieldNames(body).equals(Set.of("trackId", "reportPath")), "Attestation fields are invalid"); + var run = qualificationRuns.attachAttestation(id, text(body, "trackId"), + Path.of(text(body, "reportPath")), Instant.now()); + returnJson(exchange, 202, qualificationJson(run)); + return; + } + returnJson(exchange, 405, Map.of("error", "method_not_allowed")); + } + + private void jobRoute(HttpExchange exchange, String suffix) throws IOException { + var parts = suffix.split("/", -1); var id = parts[0]; + if (!id.matches("[A-Za-z0-9._-]{1,120}")) { returnJson(exchange, 404, Map.of("error", "job_not_found")); return; } + if (parts.length == 1 && "GET".equals(exchange.getRequestMethod())) { + var job = queue.snapshot(id); if (job.isEmpty()) returnJson(exchange, 404, Map.of("error", "job_not_found")); else returnJson(exchange, 200, jobJson(job.get())); return; + } + var cancel = (parts.length == 1 && "DELETE".equals(exchange.getRequestMethod())) + || (parts.length == 2 && "cancel".equals(parts[1]) && "POST".equals(exchange.getRequestMethod())); + if (cancel) { mutate(exchange); queue.requestCancel(id, Instant.now()); returnJson(exchange, 202, Map.of("status", "cancellation_requested")); return; } + if (parts.length == 2 && "retry".equals(parts[1]) && "POST".equals(exchange.getRequestMethod())) { + mutate(exchange); returnJson(exchange, 202, jobJson(queue.snapshot(queue.retry(id, Instant.now()).id()).orElseThrow())); return; + } + returnJson(exchange, 405, Map.of("error", "method_not_allowed")); + } + + private void exchangeDashboardSession(HttpExchange exchange) throws IOException { + secure(origin().equals(exchange.getRequestHeaders().getFirst("Origin")), "Exact loopback Origin is required"); + var body = jsonBody(exchange, 1024); require(fieldNames(body).equals(Set.of("code")), "Dashboard exchange fields are invalid"); + var session = dashboardSessions.exchange(text(body, "code"), Instant.now()); + if (session.isEmpty()) { returnJson(exchange, 401, Map.of("error", "invalid_or_expired_code")); return; } + exchange.getResponseHeaders().add("Set-Cookie", "PathLabEvidenceSession=" + session.get().token() + "; HttpOnly; SameSite=Strict; Path=/"); + returnJson(exchange, 200, Map.of("csrfToken", session.get().csrfToken(), "expiresAt", session.get().expiresAt().toString())); + } + + private void mutate(HttpExchange exchange) { + if (bearerAuthorized(exchange)) return; + var session = session(exchange).orElseThrow(() -> new IllegalArgumentException("Dashboard session is required")); + secure(origin().equals(exchange.getRequestHeaders().getFirst("Origin")), "Exact loopback Origin is required"); + var supplied = exchange.getRequestHeaders().getFirst("X-PathLab-CSRF"); + secure(supplied != null && MessageDigest.isEqual(session.csrfToken().getBytes(StandardCharsets.UTF_8), supplied.getBytes(StandardCharsets.UTF_8)), "CSRF token is invalid"); + } + + private void status(HttpExchange exchange) throws IOException { + var jobs = queue.list(null, null, 200); + var campaigns = qualificationRuns.list(100); + long queued = jobs.stream().filter(j -> j.state() == EvidenceJobState.QUEUED).count(); + long active = jobs.stream().filter(j -> !j.state().terminal() && j.state() != EvidenceJobState.QUEUED).count(); + var runtime = Runtime.getRuntime(); var telemetry = new LinkedHashMap(); + telemetry.put("processMemoryUsedBytes", runtime.totalMemory() - runtime.freeMemory()); + telemetry.put("processMemoryLimitBytes", 16L * 1024 * 1024 * 1024); + var operatingSystem = (com.sun.management.OperatingSystemMXBean) + java.lang.management.ManagementFactory.getOperatingSystemMXBean(); + telemetry.put("serviceCpuPercent", Math.max(0, operatingSystem.getProcessCpuLoad() * 100)); + telemetry.put("processCommittedVirtualMemoryBytes", operatingSystem.getCommittedVirtualMemorySize()); + telemetry.put("serviceCpuSeconds", ProcessHandle.current().info().totalCpuDuration() + .map(Duration::toMillis).orElse(0L) / 1000.0); + telemetry.put("workers", ProcessHandle.current().children().map(process -> Map.of( + "pid", process.pid(), "cpuSeconds", process.info().totalCpuDuration() + .map(Duration::toMillis).orElse(0L) / 1000.0)).toList()); + telemetry.put("diskUsableBytes", Files.getFileStore(stateRoot).getUsableSpace()); + telemetry.put("gpu", gpuTelemetry()); telemetry.put("quota", new EvidenceQuotaManager(stateRoot).snapshot().buckets()); + var status = new LinkedHashMap(); + status.put("schema", "pathlab.evidence-runner-status/2"); status.put("status", "ready"); + status.put("serviceVersion", VERSION); status.put("bootId", bootId); status.put("startedAt", startedAt.toString()); + status.put("heartbeat", Instant.now().toString()); status.put("networkDisabledForAnalysis", true); + status.put("acceptingJobs", queue.acceptingJobs()); + status.put("queue", Map.of("queued", queued, "active", active, + "gpu", jobs.stream().filter(j -> j.lane() == EvidenceExecutionLane.GPU && !j.state().terminal()).count(), + "cpuIo", jobs.stream().filter(j -> j.lane() == EvidenceExecutionLane.CPU_IO && !j.state().terminal()).count())); + status.put("qualification", Map.of( + "active", campaigns.stream().filter(run -> !run.campaignCompleted()).count(), + "completed", campaigns.stream().filter(QualificationRunSnapshot::campaignCompleted).count(), + "targetMet", campaigns.stream().filter(QualificationRunSnapshot::campaignTargetMet).count())); + status.put("acquisitions", acquisitionSnapshots()); + campaigns.stream().filter(run -> !run.campaignCompleted()).findFirst() + .ifPresent(run -> status.put("leadingQualificationRun", Map.of( + "id", run.id(), "state", run.state(), + "terminalTracks", run.tracks().stream().filter(track -> !track.verdict().isBlank()).count(), + "totalTracks", run.tracks().size()))); + jobs.stream().filter(j -> !j.state().terminal()).findFirst().ifPresent(job -> status.put("leadingJob", Map.of( + "id", job.id(), "state", job.state().name().toLowerCase(), "stage", job.stage(), + "progress", job.progress(), "lane", job.lane().wire()))); + status.put("gpuConcurrency", 1); status.put("cpuIoConcurrency", 1); status.put("vramLimitMiB", 4608); + status.put("telemetry", telemetry); + status.put("processMemoryUsedBytes", telemetry.get("processMemoryUsedBytes")); + status.put("processMemoryLimitBytes", telemetry.get("processMemoryLimitBytes")); + status.put("diskUsableBytes", telemetry.get("diskUsableBytes")); status.put("quota", telemetry.get("quota")); + returnJsonCached(exchange, status); + } + + private List> acquisitionSnapshots() { + var root = stateRoot.resolve("acquisition"); + if (!Files.isDirectory(root)) return List.of(); + try (var directories = Files.list(root)) { + return directories.filter(Files::isDirectory).sorted().limit(20).map(directory -> { + try { + var value = JSON.readTree(directory.resolve("status.json").toFile()); + require(fieldNames(value).equals(Set.of("schema", "datasetId", "state", "completedBytes", + "totalBytes", "detail", "networkContext", "analysisNetwork", "updatedAt")), + "Acquisition status fields are invalid"); + require("pathlab.acquisition-status/1".equals(value.path("schema").asText()), + "Acquisition status schema is invalid"); + var datasetId = value.path("datasetId").asText(); + var state = value.path("state").asText(); + long completed = value.path("completedBytes").asLong(-1); + long total = value.path("totalBytes").asLong(-1); + var updatedAt = value.path("updatedAt").asText(); + require(datasetId.matches("[a-z0-9][a-z0-9._-]{0,119}"), "Acquisition dataset id is invalid"); + require(Set.of("transferring", "validating", "completed", "transient_error", "failed") + .contains(state), "Acquisition state is invalid"); + require(completed >= 0 && total > 0 && completed <= total, "Acquisition byte counts are invalid"); + require("interactive-user-acquisition-only".equals(value.path("networkContext").asText()) + && "disabled".equals(value.path("analysisNetwork").asText()), + "Acquisition network boundary is invalid"); + Instant.parse(updatedAt); + return Map.of("datasetId", datasetId, "state", state, + "completedBytes", completed, "totalBytes", total, "updatedAt", updatedAt, + "networkContext", "interactive-user-acquisition-only", "analysisNetwork", "disabled"); + } catch (Exception ignored) { + return null; + } + }).filter(java.util.Objects::nonNull).toList(); + } catch (IOException ignored) { + return List.of(); + } + } + + private void listJobs(HttpExchange exchange) throws IOException { + var query = query(exchange.getRequestURI().getRawQuery()); + var allowed = Set.of("state", "lane", "limit"); require(allowed.containsAll(query.keySet()), "Unknown job-list filter"); + EvidenceJobState state = query.containsKey("state") ? EvidenceJobState.valueOf(query.get("state").toUpperCase().replace('-', '_')) : null; + EvidenceExecutionLane lane = query.containsKey("lane") ? EvidenceExecutionLane.fromWire(query.get("lane")) : null; + var limit = query.containsKey("limit") ? Integer.parseInt(query.get("limit")) : 50; + returnJsonCached(exchange, Map.of("schema", "pathlab.evidence-job-list/2", "jobs", queue.list(state, lane, limit).stream().map(EvidenceMentorRunner::jobJson).toList())); + } + + private void submit(HttpExchange exchange) throws IOException { + var value = jsonBody(exchange, 65_536); require(fieldNames(value).equals(Set.of("id", "requestPath")), "Job submission fields are invalid"); + var id = text(value, "id"); require(id.matches("[A-Za-z0-9._-]{1,120}"), "Job id is invalid"); + var requestPath = Path.of(text(value, "requestPath")); + var plan = EvidenceJobProcessor.executionPlan(requestPath, id, stateRoot); + var job = queue.submit(id, requestPath, plan.lane(), plan.packSha256(), Instant.now()); + returnJson(exchange, 202, jobJson(queue.snapshot(job.id()).orElseThrow())); + } + + private void createQualificationRun(HttpExchange exchange) throws IOException { + mutate(exchange); + var body = jsonBody(exchange, 4096); + require(fieldNames(body).equals(Set.of("manifestPath")), "Qualification submission fields are invalid"); + var manifest = QualificationCampaignManifest.load(Path.of(text(body, "manifestPath"))); + returnJson(exchange, 202, qualificationJson(qualificationRuns.create(manifest, Instant.now()))); + } + + private void listQualificationRuns(HttpExchange exchange) throws IOException { + var query = query(exchange.getRequestURI().getRawQuery()); + require(query.keySet().stream().allMatch("limit"::equals), "Unknown qualification-run filter"); + var limit = query.containsKey("limit") ? Integer.parseInt(query.get("limit")) : 20; + returnJsonCached(exchange, Map.of( + "schema", "pathlab.qualification-run-list/1", + "runs", qualificationRuns.list(limit).stream().map(EvidenceMentorRunner::qualificationJson).toList())); + } + + private void advanceQualificationRuns() { + try { qualificationRuns.tick(queue, Instant.now()); } + catch (Exception ignored) { /* durable state is retried on the next coordinator tick */ } + } + + private void control(HttpExchange exchange) throws IOException { returnJson(exchange, 200, Map.of("acceptingJobs", queue.acceptingJobs())); } + + private void processOne(EvidenceExecutionLane lane) { + if (lane == EvidenceExecutionLane.GPU) { + try (var channel = java.nio.channels.FileChannel.open(stateRoot.resolve("gpu.lock"), + java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE); + var lock = channel.tryLock()) { + if (lock != null) processClaimed(lane); + } catch (java.nio.channels.OverlappingFileLockException ignored) { + // Another runner thread or process owns the single GPU lane. + } catch (IOException ignored) { + // Failure to establish the cross-process mutex fails closed for this iteration. + } + return; + } + processClaimed(lane); + } + + private void processClaimed(EvidenceExecutionLane lane) { + var workerId = lane.wire() + "-" + ProcessHandle.current().pid(); + try { + var claimed = queue.claimNext(lane, workerId, Instant.now(), LEASE); if (claimed.isEmpty()) return; + try { new EvidenceJobProcessor(queue, stateRoot).process(claimed.get(), workerId, Instant.now()); } + catch (EvidenceJobProcessor.CancellationException ignored) { } + catch (ExternalModelWorker.ResourceLimitException resourceFailure) { + var current = queue.find(claimed.get().id()).orElseThrow(); + if (!current.state().terminal()) queue.fail(current.id(), workerId, "resource", "RESOURCE_LIMIT", + resourceFailure.getMessage(), false, Instant.now()); + } + catch (IllegalArgumentException permanent) { + var current = queue.find(claimed.get().id()).orElseThrow(); + var state = permanent.getMessage().contains("not installed") || permanent.getMessage().contains("unsupported") ? EvidenceJobState.UNSUPPORTED : EvidenceJobState.FAILED; + queue.terminate(current.id(), workerId, state, "permanent", permanentCode(permanent.getMessage()), + permanent.getMessage(), Instant.now(), LEASE); + } catch (IOException transientFailure) { + var current = queue.find(claimed.get().id()).orElseThrow(); + if (!current.state().terminal()) queue.fail(current.id(), workerId, "transient_io", "IO_TRANSIENT", transientFailure.getMessage(), true, Instant.now()); + } catch (Exception unexpected) { + var current = queue.find(claimed.get().id()).orElseThrow(); + if (!current.state().terminal()) queue.fail(current.id(), workerId, "internal", "RUNNER_INTERNAL", + "Evidence worker stopped at a durable checkpoint", false, Instant.now()); + } + } catch (Exception ignored) { /* lease expiry is the durable recovery path */ } + } + + private static Map jobJson(EvidenceJobSnapshot job) { + var result = new LinkedHashMap(); + result.put("schema", "pathlab.evidence-job/2"); result.put("id",job.id()); result.put("state",job.state().name().toLowerCase()); + result.put("stage",job.stage()); result.put("progress",job.progress()); result.put("lane",job.lane().wire()); + result.put("completedUnits",job.completedUnits()); result.put("totalUnits",job.totalUnits()); result.put("throughput",job.throughput()); + result.put("etaSeconds",job.etaSeconds()); result.put("lastHeartbeat",job.lastHeartbeat().toString()); + result.put("leaseExpiresAt",job.leaseExpiresAt().toString()); result.put("retryCount",job.retryCount()); + result.put("nextRetryAt",job.nextRetryAt().toString()); result.put("cancelRequested",job.cancelRequested()); + result.put("failureClass",job.failureClass()); result.put("failureCode",job.failureCode()); + result.put("requestSha256",job.requestSha256()); result.put("checkpointSha256",job.checkpointSha256()); + result.put("packSha256",job.packSha256()); result.put("finalArtifactSha256",job.finalArtifactSha256()); + result.put("detail",job.detail()); result.put("updatedAt",job.updatedAt().toString()); return result; + } + + private static Map qualificationJson(QualificationRunSnapshot run) { + var result = new LinkedHashMap(); + result.put("schema", "pathlab.qualification-run/1"); + result.put("id", run.id()); + result.put("manifestSha256", run.manifestSha256()); + result.put("state", run.state()); + result.put("campaignCompleted", run.campaignCompleted()); + result.put("campaignTargetMet", run.campaignTargetMet()); + result.put("cancelRequested", run.cancelRequested()); + result.put("tracks", run.tracks().stream().map(track -> Map.ofEntries( + Map.entry("id", track.id()), Map.entry("candidateId", track.candidateId()), + Map.entry("capability", track.capability()), Map.entry("scope", track.scope()), + Map.entry("state", track.state()), Map.entry("verdict", track.verdict()), + Map.entry("attempt", track.attempt()), Map.entry("jobId", track.jobId()), + Map.entry("artifactSha256", track.artifactSha256()), + Map.entry("failureCode", track.failureCode()), Map.entry("detail", track.detail()), + Map.entry("updatedAt", track.updatedAt().toString()))).toList()); + result.put("createdAt", run.createdAt().toString()); + result.put("updatedAt", run.updatedAt().toString()); + return result; + } + + private boolean authenticated(HttpExchange exchange) { return bearerAuthorized(exchange) || session(exchange).isPresent(); } + private boolean bearerAuthorized(HttpExchange exchange) { + var supplied=exchange.getRequestHeaders().getFirst("Authorization"); if(supplied==null)return false; + return MessageDigest.isEqual(("Bearer "+token).getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8)); + } + private Optional session(HttpExchange exchange) { + var cookie=exchange.getRequestHeaders().getFirst("Cookie"); if(cookie==null)return Optional.empty(); + for(var part:cookie.split(";")){var item=part.trim();if(item.startsWith("PathLabEvidenceSession="))return dashboardSessions.find(item.substring(item.indexOf('=')+1),Instant.now());} + return Optional.empty(); + } + private boolean validHost(HttpExchange exchange){return ("127.0.0.1:"+server.getAddress().getPort()).equals(exchange.getRequestHeaders().getFirst("Host"));} + private boolean validOriginWhenPresent(HttpExchange exchange){var origin=exchange.getRequestHeaders().getFirst("Origin");return origin==null||origin().equals(origin);} + private String origin(){return "http://127.0.0.1:"+server.getAddress().getPort();} + + private void publishEndpoint() throws IOException { + var body=JSON.writeValueAsBytes(Map.of("schema","pathlab.runner-endpoint/1","port",server.getAddress().getPort(),"serviceVersion",VERSION, + "bootId",bootId,"pid",ProcessHandle.current().pid(),"startedAt",startedAt.toString())); + var partial=endpointPath.resolveSibling("endpoint.json.partial"); Files.write(partial,body); + preserveEndpointAcl(endpointPath, partial); + try{Files.move(partial,endpointPath,java.nio.file.StandardCopyOption.REPLACE_EXISTING,java.nio.file.StandardCopyOption.ATOMIC_MOVE);} + catch(java.nio.file.AtomicMoveNotSupportedException ignored){Files.move(partial,endpointPath,java.nio.file.StandardCopyOption.REPLACE_EXISTING);} + } + + private static void preserveEndpointAcl(Path endpoint, Path partial) throws IOException { + if (!Files.exists(endpoint)) return; + var source = Files.getFileAttributeView(endpoint, AclFileAttributeView.class); + var target = Files.getFileAttributeView(partial, AclFileAttributeView.class); + if (source != null && target != null) target.setAcl(source.getAcl()); + } + + private static Map query(String raw){var result=new java.util.HashMap();if(raw==null||raw.isBlank())return result;for(var part:raw.split("&")){var pair=part.split("=",2);require(pair.length==2&&!pair[0].isBlank(),"Invalid query");result.put(pair[0],java.net.URLDecoder.decode(pair[1],StandardCharsets.UTF_8));}return result;} + private static JsonNode jsonBody(HttpExchange exchange,int limit)throws IOException{var raw=exchange.getRequestBody().readNBytes(limit+1);require(raw.length<=limit,"Request body is too large");var value=JSON.readTree(raw);require(value!=null&&value.isObject(),"JSON object required");return value;} + private static Set fieldNames(JsonNode value){var result=new java.util.HashSet();value.fieldNames().forEachRemaining(result::add);return result;} + private static String text(JsonNode value,String field){var node=value.path(field);require(node.isTextual()&&!node.textValue().isBlank(),"Field is invalid: "+field);return node.textValue();} + private static void returnJson(HttpExchange exchange,int status,Object body)throws IOException{if(exchange.getResponseCode()!=-1)return;var payload=JSON.writeValueAsBytes(body);exchange.getResponseHeaders().set("Content-Type","application/json; charset=utf-8");exchange.getResponseHeaders().set("Cache-Control","no-store");exchange.getResponseHeaders().set("X-Content-Type-Options","nosniff");exchange.sendResponseHeaders(status,payload.length);exchange.getResponseBody().write(payload);} + private static void returnJsonCached(HttpExchange exchange,Object body)throws IOException{var payload=JSON.writeValueAsBytes(body);var etag="\""+digest(payload)+"\"";exchange.getResponseHeaders().set("ETag",etag);exchange.getResponseHeaders().set("Cache-Control","private, max-age=0, must-revalidate");if(etag.equals(exchange.getRequestHeaders().getFirst("If-None-Match"))){exchange.sendResponseHeaders(304,-1);return;}exchange.getResponseHeaders().set("Content-Type","application/json; charset=utf-8");exchange.getResponseHeaders().set("X-Content-Type-Options","nosniff");exchange.sendResponseHeaders(200,payload.length);exchange.getResponseBody().write(payload);} + private void returnHtml(HttpExchange exchange)throws IOException{var payload=dashboardHtml.getBytes(StandardCharsets.UTF_8);exchange.getResponseHeaders().set("Content-Type","text/html; charset=utf-8");exchange.getResponseHeaders().set("Cache-Control","no-store");exchange.getResponseHeaders().set("Content-Security-Policy","default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'");exchange.sendResponseHeaders(200,payload.length);exchange.getResponseBody().write(payload);} + private static String readResource(String name)throws IOException{try(var input=EvidenceMentorRunner.class.getResourceAsStream(name)){if(input==null)throw new IOException("Dashboard resource missing");return new String(input.readAllBytes(),StandardCharsets.UTF_8);}} + private static Map gpuTelemetry(){try{var process=new ProcessBuilder("nvidia-smi","--query-gpu=utilization.gpu,memory.used,memory.total","--format=csv,noheader,nounits").redirectErrorStream(true).start();if(!process.waitFor(2,TimeUnit.SECONDS)){process.destroyForcibly();return Map.of("available",false);}var parts=new String(process.getInputStream().readAllBytes(),StandardCharsets.UTF_8).trim().split(",");if(process.exitValue()!=0||parts.length<3)return Map.of("available",false);return Map.of("available",true,"utilizationPercent",Integer.parseInt(parts[0].trim()),"vramUsedMiB",Integer.parseInt(parts[1].trim()),"vramTotalMiB",Integer.parseInt(parts[2].trim()));}catch(Exception ignored){return Map.of("available",false);}} + private static Thread daemon(Runnable runnable,String name,int priority){var thread=new Thread(runnable,name);thread.setDaemon(true);thread.setPriority(priority);return thread;} + private static String safe(String value){return value==null?"Request was refused":value;} + private static String digest(byte[] value){try{return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));}catch(java.security.GeneralSecurityException impossible){throw new IllegalStateException("SHA-256 unavailable",impossible);}} + private static String permanentCode(String detail){var value=detail==null?"":detail.toLowerCase();if(value.contains("license")||value.contains("rights"))return "RIGHTS_OR_LICENSE";if(value.contains("checksum"))return "CHECKSUM_MISMATCH";if(value.contains("stain"))return "STAIN_UNSUPPORTED";if(value.contains("schema"))return "SCHEMA_UNSUPPORTED";if(value.contains("not installed"))return "PACK_NOT_INSTALLED";if(value.contains("unsupported"))return "CAPABILITY_UNSUPPORTED";return "VALIDATION_FAILED";} + private static void require(boolean condition,String message){if(!condition)throw new IllegalArgumentException(message);} + private static void secure(boolean condition,String message){if(!condition)throw new SecurityException(message);} + + @Override public void close()throws IOException{server.stop(0);gpuWorker.shutdownNow();cpuWorker.shutdownNow();campaignWorker.shutdownNow();http.shutdownNow();try{if(Files.isRegularFile(endpointPath)){var endpoint=JSON.readTree(endpointPath.toFile());if(bootId.equals(endpoint.path("bootId").asText()))Files.deleteIfExists(endpointPath);}}finally{try{qualificationRuns.close();}finally{queue.close();}}} + + public static void main(String[] args)throws Exception{ + var state=defaultStateRoot();var port=0; + for(var i=0;i{try{runner.close();}catch(IOException ignored){}}));new CountDownLatch(1).await(); + } + public static Path defaultStateRoot(){var configured=System.getenv("PATHLAB_EVIDENCE_STATE");if(configured!=null&&!configured.isBlank())return Path.of(configured);var data=Path.of("D:\\PathLabData");if(Files.isDirectory(data))return data.resolve("EvidenceMentor/state");var local=System.getenv("LOCALAPPDATA");return Path.of(local==null?System.getProperty("user.home"):local).resolve("PathLab/EvidenceMentor/state");} +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidencePackManifest.java b/src/main/java/org/pathlab/forge/evidence/EvidencePackManifest.java new file mode 100644 index 0000000..ed36b04 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidencePackManifest.java @@ -0,0 +1,400 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** Validated, immutable description of one locally installed Evidence Mentor pack. */ +public record EvidencePackManifest( + Path path, + String schema, + String packId, + String version, + Capability capability, + Set acceptedStains, + String preprocessingId, + int tilePixels, + String allowedUse, + String scope, + boolean activationEligible, + boolean redistributable, + boolean derivativesAllowed, + boolean acceptanceOnly, + RuntimeCompatibility runtimeCompatibility, + List licenseLedger, + int maxRamMiB, + int maxVramMiB, + int maxSeconds, + ValidationStatus validationStatus, + String sha256, + List artifacts, + Set markers) { + public static final String SCHEMA = "pathlab.ai-pack/1"; + public static final String SCHEMA_V2 = "pathlab.ai-pack/2"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern ID = Pattern.compile("[a-z0-9][a-z0-9._-]{0,119}"); + private static final Pattern SHA = Pattern.compile("[a-f0-9]{64}"); + private static final Set STAINS = Set.of("he", "ihc_dab", "pas", "pas_d", "trichrome", + "gms", "afb", "papanicolaou", "generic_brightfield"); + private static final Set MARKERS = Set.of("generic", "er", "pr", "ki-67", "her2", "pd-l1"); + + public static EvidencePackManifest load(Path manifest) throws IOException { + var normalized = manifest.toAbsolutePath().normalize(); + var bytes = Files.readAllBytes(normalized); + var root = JSON.readTree(bytes); + require(root.isObject(), "AI pack must be a JSON object"); + var schema = text(root, "schema"); + require(Set.of(SCHEMA, SCHEMA_V2).contains(schema), "AI pack schema is unsupported"); + if (SCHEMA_V2.equals(schema)) return loadV2(normalized, bytes, root); + var packId = text(root, "packId"); + require(ID.matcher(packId).matches(), "AI pack id is invalid"); + var version = text(root, "version"); + require(version.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"), "AI pack version is invalid"); + var capability = Capability.fromWire(text(root, "capability")); + var acceptedStains = stringSet(root.path("acceptedStains"), STAINS, "accepted stain"); + require(!acceptedStains.isEmpty(), "AI pack requires an accepted stain"); + var preprocessing = object(root, "preprocessing"); + var preprocessingId = text(preprocessing, "id"); + require(preprocessingId.length() <= 120, "AI pack preprocessing id is invalid"); + var tilePixels = boundedInt(preprocessing, "tilePixels", 64, 2_048); + var rights = object(root, "rights"); + var allowedUse = text(rights, "allowedUse"); + require(Set.of("private-research", "benchmark-only").contains(allowedUse), "AI pack allowed use is invalid"); + var resources = object(root, "resourceEnvelope"); + var maxRam = boundedInt(resources, "maxRamMiB", 64, 16_384); + var maxVram = boundedInt(resources, "maxVramMiB", 0, 4_608); + var maxSeconds = boundedInt(resources, "maxSeconds", 1, 1_200); + require(resources.has("network") && !resources.path("network").asBoolean(true), + "Analysis packs must be offline"); + var validation = object(root, "validation"); + var status = ValidationStatus.fromWire(text(validation, "status")); + var acceptanceOnly = false; + if (root.has("usageLimits")) { + var usageLimits = object(root, "usageLimits"); + require(usageLimits.size() == 1 && usageLimits.path("acceptanceOnly").isBoolean(), + "AI pack usage limits are invalid"); + acceptanceOnly = usageLimits.path("acceptanceOnly").booleanValue(); + } + var artifacts = parseArtifacts(root.path("artifacts")); + var markers = root.has("markers") + ? stringSet(root.path("markers"), MARKERS, "marker") + : Set.of(); + if (capability == Capability.IHC_DESCRIPTIVE) { + require(markers.contains("generic"), "IHC packs must include the generic marker"); + } else { + require(markers.isEmpty(), "Only IHC packs may declare markers"); + } + var runtime = root.has("runtimeCompatibility") + ? parseRuntime(root.path("runtimeCompatibility")) + : new RuntimeCompatibility("java17", "cpu", "none", "none", false); + if (acceptanceOnly) { + require("benchmark-only".equals(allowedUse) + && status == ValidationStatus.NOT_EVALUABLE + && runtime.requiresExternalWorker(), + "Acceptance-only packs must remain benchmark-only, not-evaluable external workers"); + } + var licenseLedger = root.has("licenseLedger") + ? parseLicenseLedger(root.path("licenseLedger")) : List.of(); + return new EvidencePackManifest( + normalized, SCHEMA, packId, version, capability, acceptedStains, preprocessingId, tilePixels, allowedUse, + "benchmark-only".equals(allowedUse) ? "local-benchmark" : "deployment", + false, + booleanValue(rights, "redistributable"), + booleanValue(rights, "derivativesAllowed"), + acceptanceOnly, + runtime, licenseLedger, + maxRam, maxVram, maxSeconds, status, sha256(bytes), artifacts, markers); + } + + private static EvidencePackManifest loadV2(Path normalized, byte[] bytes, JsonNode root) throws IOException { + exact(root, Set.of("schema", "packId", "version", "capability", "scope", "activationEligible", + "acceptedStains", "preprocessing", "artifacts", "runtimeCompatibility", "licenseLedger", + "derivativePermissions", "resourceEnvelope", "qualificationPolicy", "outputSchema", "markers")); + var packId = text(root, "packId"); + require(ID.matcher(packId).matches(), "AI pack id is invalid"); + var version = text(root, "version"); + require(version.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"), "AI pack version is invalid"); + var capability = Capability.fromWire(text(root, "capability")); + var scope = text(root, "scope"); + require(Set.of("deployment", "research-restricted", "local-benchmark").contains(scope), + "AI pack scope is invalid"); + var activationEligible = booleanValue(root, "activationEligible"); + require(!("local-benchmark".equals(scope) && activationEligible), + "Benchmark-only packs cannot be activation eligible"); + var stains = stringSet(root.path("acceptedStains"), STAINS, "accepted stain"); + require(!stains.isEmpty(), "AI pack requires an accepted stain"); + var preprocessing = object(root, "preprocessing"); + var preprocessingId = text(preprocessing, "id"); + var tilePixels = boundedInt(preprocessing, "tilePixels", 64, 2_048); + var resources = object(root, "resourceEnvelope"); + exact(resources, Set.of("lane", "maxRamMiB", "maxVramMiB", "maxSeconds", "network")); + require(Set.of("gpu", "cpu_io", "external").contains(text(resources, "lane")), + "AI pack lane is invalid"); + var maxRam = boundedInt(resources, "maxRamMiB", 64, 16_384); + var maxVram = boundedInt(resources, "maxVramMiB", 0, 4_608); + var maxSeconds = boundedInt(resources, "maxSeconds", 1, 1_200); + require(!resources.path("network").asBoolean(true), "Analysis packs must be offline"); + var runtime = parseRuntimeV2(root.path("runtimeCompatibility")); + var ledger = parseLicenseLedgerV2(root.path("licenseLedger")); + var permissions = object(root, "derivativePermissions"); + exact(permissions, Set.of("atlasResearch", "atlasClean", "reviewedAt")); + require(permissions.path("atlasResearch").isBoolean() && permissions.path("atlasClean").isBoolean(), + "AI pack derivative permissions are invalid"); + var policy = object(root, "qualificationPolicy"); + exact(policy, Set.of("requiresSignedAttestation", "protocolSha256")); + require(policy.path("requiresSignedAttestation").asBoolean(false) + && text(policy, "protocolSha256").matches("[a-f0-9]{64}"), + "AI pack qualification policy is invalid"); + require(Set.of("pathlab.ai-evidence/2", "pathlab.model-qualification-report/2", + "pathlab.model-worker-result/2").contains(text(root, "outputSchema")), + "AI pack output schema is invalid"); + var artifacts = parseArtifactsV2(root.path("artifacts")); + var markers = stringSet(root.path("markers"), MARKERS, "marker"); + if (capability == Capability.IHC_DESCRIPTIVE) require(markers.contains("generic"), + "IHC packs must include the generic marker"); + else require(markers.isEmpty(), "Only IHC packs may declare markers"); + var allowedUse = switch (scope) { + case "deployment" -> "private-research"; + case "research-restricted" -> "research-restricted"; + default -> "benchmark-only"; + }; + return new EvidencePackManifest(normalized, SCHEMA_V2, packId, version, capability, stains, + preprocessingId, tilePixels, allowedUse, scope, activationEligible, false, + permissions.path("atlasResearch").asBoolean(false), false, runtime, ledger, + maxRam, maxVram, maxSeconds, ValidationStatus.NOT_EVALUABLE, sha256(bytes), artifacts, markers); + } + + public boolean pilotEligible() { + return SCHEMA.equals(schema) && !acceptanceOnly && "private-research".equals(allowedUse) + && validationStatus == ValidationStatus.QUALIFIED; + } + + public void requirePilotEligible() { + require(pilotEligible(), "AI pack is not eligible for the private pilot"); + } + + public void requireExecutableForJob(String jobId) { + if (acceptanceOnly) { + require(jobId != null && jobId.matches("acceptance-[a-f0-9]{8,64}"), + "Acceptance-only AI pack requires a non-identifying acceptance job id"); + return; + } + if (jobId != null && jobId.matches("qualification-[a-f0-9]{8,64}")) { + if (SCHEMA_V2.equals(schema)) return; + require(("private-research".equals(allowedUse) + && validationStatus == ValidationStatus.EXPERIMENTAL) + || (Set.of("private-research", "benchmark-only").contains(allowedUse) + && validationStatus == ValidationStatus.NOT_EVALUABLE + && runtimeCompatibility.requiresExternalWorker()), + "AI pack is not eligible for qualification execution"); + return; + } + requirePilotEligible(); + } + + public void requireExecutableForJob(String jobId, QualifiedPackRegistry registry, + String qualificationAttestationSha256) throws IOException { + if (!SCHEMA_V2.equals(schema) || (jobId != null + && jobId.matches("qualification-[a-f0-9]{8,64}"))) { + requireExecutableForJob(jobId); + return; + } + require(activationEligible && !"local-benchmark".equals(scope), + "AI pack candidate is not activation eligible"); + registry.requireQualified(this, qualificationAttestationSha256); + } + + public void requireStain(String stain) { + require(acceptedStains.contains(stain), "AI pack does not support stain " + stain); + } + + private static List parseArtifacts(JsonNode node) { + require(node.isArray() && node.size() <= 16, "AI pack artifacts are invalid"); + var result = new java.util.ArrayList(); + for (var item : node) { + var name = text(item, "name"); + var hash = text(item, "sha256"); + var source = text(item, "source"); + require(name.length() <= 160 && SHA.matcher(hash).matches() + && source.length() <= 1_000, "AI pack artifact is invalid"); + result.add(new Artifact(name, hash, source)); + } + return List.copyOf(result); + } + + private static List parseArtifactsV2(JsonNode node) { + require(node.isArray() && node.size() <= 32, "AI pack artifacts are invalid"); + var result = new java.util.ArrayList(); + for (var item : node) { + exact(item, Set.of("name", "sha256", "source")); + var name = text(item, "name"); + var hash = text(item, "sha256"); + var source = text(item, "source"); + require(name.length() <= 160 && SHA.matcher(hash).matches() + && source.length() <= 1_000, "AI pack artifact is invalid"); + result.add(new Artifact(name, hash, source)); + } + return List.copyOf(result); + } + + private static RuntimeCompatibility parseRuntime(JsonNode node) { + require(node.isObject(), "AI pack runtime compatibility is invalid"); + var protocol = text(node, "workerProtocol"); + var provider = text(node, "executionProvider"); + var cuda = text(node, "cuda"); + var architecture = text(node, "gpuArchitecture"); + require(protocol.length() <= 80 && Set.of("cpu", "cuda").contains(provider) + && cuda.length() <= 40 && architecture.length() <= 40, + "AI pack runtime compatibility is invalid"); + return new RuntimeCompatibility(protocol, provider, cuda, architecture, + node.path("requiresExternalWorker").asBoolean(false)); + } + + private static RuntimeCompatibility parseRuntimeV2(JsonNode node) { + exact(node, Set.of("workerProtocol", "executionProvider", "cuda", "gpuArchitecture", + "requiresExternalWorker", "segmentMaxSeconds")); + var protocol = text(node, "workerProtocol"); + var provider = text(node, "executionProvider"); + require(Set.of("pathlab.model-worker-result/1", "pathlab.model-worker-result/2").contains(protocol) + && Set.of("cpu", "cuda", "webgpu", "external").contains(provider), + "AI pack runtime compatibility is invalid"); + boundedInt(node, "segmentMaxSeconds", 1, 1_200); + return new RuntimeCompatibility(protocol, provider, text(node, "cuda"), + text(node, "gpuArchitecture"), node.path("requiresExternalWorker").asBoolean(false)); + } + + private static List parseLicenseLedger(JsonNode node) { + require(node.isArray() && node.size() <= 32, "AI pack license ledger is invalid"); + var result = new java.util.ArrayList(); + for (var item : node) { + var component = text(item, "component"); + var license = text(item, "license"); + var revision = text(item, "revision"); + var permittedUse = text(item, "permittedUse"); + require(component.length() <= 160 && license.length() <= 200 && revision.length() <= 160 + && Set.of("private-research", "benchmark-only").contains(permittedUse), + "AI pack license ledger is invalid"); + result.add(new LicenseEntry(component, "weights", license, revision, permittedUse, + item.path("redistributable").asBoolean(false), + item.path("derivativesAllowed").asBoolean(false))); + } + return List.copyOf(result); + } + + private static List parseLicenseLedgerV2(JsonNode node) { + require(node.isArray() && !node.isEmpty() && node.size() <= 64, "AI pack license ledger is invalid"); + var result = new java.util.ArrayList(); + for (var item : node) { + exact(item, Set.of("component", "kind", "license", "revision", "permittedUse", + "redistributable", "derivativesAllowed")); + var kind = text(item, "kind"); + var permittedUse = text(item, "permittedUse"); + require(Set.of("code", "weights", "dataset", "runtime").contains(kind) + && Set.of("private-research", "research-restricted", "benchmark-only").contains(permittedUse), + "AI pack license ledger is invalid"); + result.add(new LicenseEntry(text(item, "component"), kind, text(item, "license"), + text(item, "revision"), permittedUse, booleanValue(item, "redistributable"), + booleanValue(item, "derivativesAllowed"))); + } + return List.copyOf(result); + } + + private static Set stringSet(JsonNode node, Set allowed, String label) { + require(node.isArray() && node.size() <= 16, "AI pack " + label + " list is invalid"); + var result = new java.util.LinkedHashSet(); + for (var item : node) { + require(item.isTextual() && allowed.contains(item.textValue()), + "AI pack " + label + " is invalid"); + require(result.add(item.textValue()), "AI pack " + label + " is duplicated"); + } + return Set.copyOf(result); + } + + private static JsonNode object(JsonNode root, String name) { + var value = root.path(name); + require(value.isObject(), "AI pack " + name + " is invalid"); + return value; + } + + private static void exact(JsonNode node, Set expected) { + require(node.isObject(), "AI pack object is invalid"); + var actual = new java.util.HashSet(); + node.fieldNames().forEachRemaining(actual::add); + require(actual.equals(expected), "AI pack fields are invalid"); + } + + private static String text(JsonNode root, String name) { + var value = root.path(name); + require(value.isTextual() && !value.textValue().isBlank(), "AI pack " + name + " is invalid"); + return value.textValue(); + } + + private static int boundedInt(JsonNode root, String name, int minimum, int maximum) { + var value = root.path(name); + require(value.isIntegralNumber() && value.canConvertToInt() + && value.intValue() >= minimum && value.intValue() <= maximum, + "AI pack " + name + " is outside the supported resource envelope"); + return value.intValue(); + } + + private static boolean booleanValue(JsonNode root, String name) { + var value = root.path(name); + require(value.isBoolean(), "AI pack " + name + " is invalid"); + return value.booleanValue(); + } + + private static String sha256(byte[] value) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (Exception error) { + throw new IOException("SHA-256 is unavailable", error); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + public record Artifact(String name, String sha256, String source) {} + public record RuntimeCompatibility(String workerProtocol, String executionProvider, String cuda, + String gpuArchitecture, boolean requiresExternalWorker) {} + public record LicenseEntry(String component, String kind, String license, String revision, String permittedUse, + boolean redistributable, boolean derivativesAllowed) {} + + public enum Capability { + HE_EVIDENCE("he-evidence"), + CELL_MORPHOLOGY("cell-morphology"), + IHC_DESCRIPTIVE("ihc-descriptive"), + SPECIAL_STAIN_DESCRIPTIVE("special-stain-descriptive"), + CYTOLOGY_DESCRIPTIVE("cytology-descriptive"), + GROUNDED_TUTOR("grounded-tutor"), + ATLAS_DISTILLATION("atlas-distillation"); + + private final String wire; + Capability(String wire) { this.wire = wire; } + public String wire() { return wire; } + static Capability fromWire(String value) { + return java.util.Arrays.stream(values()).filter(item -> item.wire.equals(value)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("AI pack capability is invalid")); + } + } + + public enum ValidationStatus { + EXPERIMENTAL("experimental"), QUALIFIED("qualified"), NOT_EVALUABLE("not-evaluable"), + UNSUPPORTED("unsupported"), BLOCKED("blocked"); + private final String wire; + ValidationStatus(String wire) { this.wire = wire; } + public String wire() { return wire; } + static ValidationStatus fromWire(String value) { + return java.util.Arrays.stream(values()).filter(item -> item.wire.equals(value)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("AI pack validation status is invalid")); + } + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceQualificationCohort.java b/src/main/java/org/pathlab/forge/evidence/EvidenceQualificationCohort.java new file mode 100644 index 0000000..aae4c91 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceQualificationCohort.java @@ -0,0 +1,232 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** Frozen patient/source-held-out cohort definition; it reports readiness but never qualifies a model. */ +public record EvidenceQualificationCohort( + Path path, + String cohortId, + Instant frozenAt, + String intendedUse, + AcceptanceCriteria acceptanceCriteria, + List samples) { + public static final String SCHEMA = "pathlab.qualification-cohort/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern ID = Pattern.compile("[a-z0-9][a-z0-9._-]{0,119}"); + private static final Pattern SHA = Pattern.compile("[a-f0-9]{64}"); + private static final Set GROUPS = Set.of( + "breast", "gi", "lung", "lymph-node", "benign-reactive"); + private static final Set SPLITS = Set.of("reference", "query", "ood"); + private static final Set ROOT_FIELDS = Set.of( + "schema", "cohortId", "frozenAt", "intendedUse", "acceptanceCriteria", "samples"); + private static final Set CRITERIA_FIELDS = Set.of( + "requiredEvaluationGroups", "minimumReferenceSamplesPerGroup", + "minimumQuerySamplesPerGroup", "minimumOodSamples", "maximumPatientOverlap", + "maximumSlideOverlap", "maximumSourceOverlap", "baselineId", + "minimumMacroRecallAt5Improvement", "minimumMacroNdcgAt10Improvement", + "minimumOodAuRoc"); + private static final Set SAMPLE_FIELDS = Set.of( + "id", "tileCacheManifest", "tileCacheManifestSha256", "evaluationGroup", + "phenotypeGroup", "split", "sourceGroup"); + + public static EvidenceQualificationCohort load(Path manifest) throws IOException { + var normalized = manifest.toAbsolutePath().normalize(); + var root = JSON.readTree(normalized.toFile()); + require(root.isObject() && fields(root).equals(ROOT_FIELDS), + "Qualification cohort fields are invalid"); + require(SCHEMA.equals(text(root, "schema", 80)), "Qualification cohort schema is unsupported"); + var cohortId = text(root, "cohortId", 120); + require(ID.matcher(cohortId).matches(), "Qualification cohort id is invalid"); + final Instant frozenAt; + try { + frozenAt = Instant.parse(text(root, "frozenAt", 80)); + } catch (java.time.format.DateTimeParseException error) { + throw new IllegalArgumentException("Qualification cohort freeze timestamp is invalid", error); + } + var intendedUse = text(root, "intendedUse", 80); + require("private-research-model-qualification".equals(intendedUse), + "Qualification cohort intended use is invalid"); + var criteria = parseCriteria(root.path("acceptanceCriteria")); + var nodes = root.path("samples"); + require(nodes.isArray() && !nodes.isEmpty() && nodes.size() <= 20_000, + "Qualification cohort samples are invalid"); + var rootDirectory = normalized.getParent().toRealPath(); + var ids = new HashSet(); + var samples = new ArrayList(); + for (var node : nodes) { + require(node.isObject() && fields(node).equals(SAMPLE_FIELDS), + "Qualification sample fields are invalid"); + var id = text(node, "id", 120); + require(ID.matcher(id).matches() && ids.add(id), + "Qualification sample id is invalid or duplicated"); + var relative = Path.of(text(node, "tileCacheManifest", 500)); + require(!relative.isAbsolute(), "Qualification sample paths must be relative"); + var tileManifest = rootDirectory.resolve(relative).normalize(); + require(tileManifest.startsWith(rootDirectory) && Files.isRegularFile(tileManifest), + "Qualification tile cache escaped its frozen root or is unavailable"); + tileManifest = tileManifest.toRealPath(); + require(tileManifest.startsWith(rootDirectory), "Qualification tile cache escaped its frozen root"); + var tileManifestSha = text(node, "tileCacheManifestSha256", 64); + require(SHA.matcher(tileManifestSha).matches(), "Qualification tile-cache checksum is invalid"); + var tileCache = EvidenceTileCacheManifest.load(tileManifest, tileManifestSha); + var group = text(node, "evaluationGroup", 80); + require(GROUPS.contains(group), "Qualification evaluation group is invalid"); + var phenotype = text(node, "phenotypeGroup", 160); + var split = text(node, "split", 20); + require(SPLITS.contains(split), "Qualification split is invalid"); + var sourceGroup = text(node, "sourceGroup", 160); + samples.add(new Sample(id, tileManifest, tileManifestSha, group, phenotype, + split, sourceGroup, tileCache)); + } + return new EvidenceQualificationCohort(normalized, cohortId, frozenAt, intendedUse, + criteria, List.copyOf(samples)); + } + + public Assessment assessReadiness() { + var reasons = new LinkedHashSet(); + var referenceCounts = counts("reference"); + var queryCounts = counts("query"); + for (var group : acceptanceCriteria.requiredEvaluationGroups()) { + if (referenceCounts.getOrDefault(group, 0) < acceptanceCriteria.minimumReferenceSamplesPerGroup()) { + reasons.add("INSUFFICIENT_REFERENCE_" + wireReason(group)); + } + if (queryCounts.getOrDefault(group, 0) < acceptanceCriteria.minimumQuerySamplesPerGroup()) { + reasons.add("INSUFFICIENT_QUERY_" + wireReason(group)); + } + } + var ood = samples.stream().filter(sample -> "ood".equals(sample.split())).count(); + if (ood < acceptanceCriteria.minimumOodSamples()) reasons.add("INSUFFICIENT_OOD"); + if (overlap(sample -> sample.tileCache().sample().source() + "::" + + sample.tileCache().sample().patientGroup()) > acceptanceCriteria.maximumPatientOverlap()) { + reasons.add("PATIENT_SPLIT_OVERLAP"); + } + if (overlap(sample -> sample.tileCache().sample().source() + "::" + + sample.tileCache().sample().slideId()) > acceptanceCriteria.maximumSlideOverlap()) { + reasons.add("SLIDE_SPLIT_OVERLAP"); + } + if (overlap(Sample::sourceGroup) > acceptanceCriteria.maximumSourceOverlap()) { + reasons.add("SOURCE_SPLIT_OVERLAP"); + } + if (samples.stream().anyMatch(sample -> + !"private-research".equals(sample.tileCache().sample().permittedUse()))) { + reasons.add("UNAPPROVED_SAMPLE_RIGHTS"); + } + return new Assessment(reasons.isEmpty() ? "ready" : "not_evaluable", List.copyOf(reasons)); + } + + private Map counts(String split) { + var result = new HashMap(); + samples.stream().filter(sample -> split.equals(sample.split())).forEach(sample -> + result.merge(sample.evaluationGroup(), 1, Integer::sum)); + return result; + } + + private int overlap(java.util.function.Function key) { + var splitsByKey = new HashMap>(); + for (var sample : samples) { + splitsByKey.computeIfAbsent(key.apply(sample), ignored -> new HashSet<>()).add(sample.split()); + } + return (int) splitsByKey.values().stream().filter(splits -> splits.size() > 1).count(); + } + + private static AcceptanceCriteria parseCriteria(JsonNode node) { + require(node.isObject() && fields(node).equals(CRITERIA_FIELDS), + "Qualification acceptance criteria are invalid"); + var groupsNode = node.path("requiredEvaluationGroups"); + require(groupsNode.isArray(), "Qualification evaluation groups are invalid"); + var groups = new HashSet(); + groupsNode.forEach(value -> { + require(value.isTextual() && GROUPS.contains(value.textValue()) && groups.add(value.textValue()), + "Qualification evaluation groups are invalid or duplicated"); + }); + require(groups.equals(GROUPS), "Qualification cohort must pre-register every MVP evaluation group"); + var baseline = text(node, "baselineId", 120); + require(ID.matcher(baseline).matches(), "Qualification baseline id is invalid"); + var patientOverlap = boundedInt(node, "maximumPatientOverlap", 0, 0); + var slideOverlap = boundedInt(node, "maximumSlideOverlap", 0, 0); + var sourceOverlap = boundedInt(node, "maximumSourceOverlap", 0, 0); + return new AcceptanceCriteria(Set.copyOf(groups), + boundedInt(node, "minimumReferenceSamplesPerGroup", 1, 10_000), + boundedInt(node, "minimumQuerySamplesPerGroup", 1, 10_000), + boundedInt(node, "minimumOodSamples", 1, 10_000), + patientOverlap, slideOverlap, sourceOverlap, baseline, + boundedDouble(node, "minimumMacroRecallAt5Improvement", 0, 1), + boundedDouble(node, "minimumMacroNdcgAt10Improvement", 0, 1), + boundedDouble(node, "minimumOodAuRoc", 0.5, 1)); + } + + private static int boundedInt(JsonNode node, String field, int minimum, int maximum) { + var value = node.path(field); + require(value.isIntegralNumber() && value.canConvertToInt() + && value.intValue() >= minimum && value.intValue() <= maximum, + "Qualification criterion is invalid: " + field); + return value.intValue(); + } + + private static double boundedDouble(JsonNode node, String field, double minimum, double maximum) { + var value = node.path(field); + require(value.isNumber() && Double.isFinite(value.doubleValue()) + && value.doubleValue() >= minimum && value.doubleValue() <= maximum, + "Qualification criterion is invalid: " + field); + return value.doubleValue(); + } + + private static Set fields(JsonNode node) { + var result = new HashSet(); + node.fieldNames().forEachRemaining(result::add); + return result; + } + + private static String text(JsonNode node, String field, int maximum) { + var value = node.path(field); + require(value.isTextual() && !value.textValue().isBlank() && value.textValue().length() <= maximum, + "Qualification field is invalid: " + field); + return value.textValue(); + } + + private static String wireReason(String value) { + return value.toUpperCase(java.util.Locale.ROOT).replace('-', '_'); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + public record AcceptanceCriteria( + Set requiredEvaluationGroups, + int minimumReferenceSamplesPerGroup, + int minimumQuerySamplesPerGroup, + int minimumOodSamples, + int maximumPatientOverlap, + int maximumSlideOverlap, + int maximumSourceOverlap, + String baselineId, + double minimumMacroRecallAt5Improvement, + double minimumMacroNdcgAt10Improvement, + double minimumOodAuRoc) {} + + public record Sample( + String id, + Path tileCacheManifest, + String tileCacheManifestSha256, + String evaluationGroup, + String phenotypeGroup, + String split, + String sourceGroup, + EvidenceTileCacheManifest tileCache) {} + + public record Assessment(String status, List reasons) {} +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceQuotaManager.java b/src/main/java/org/pathlab/forge/evidence/EvidenceQuotaManager.java new file mode 100644 index 0000000..75ffb8e --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceQuotaManager.java @@ -0,0 +1,104 @@ +package org.pathlab.forge.evidence; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Hard, fail-closed quota accounting for newly acquired Evidence Mentor material. */ +public final class EvidenceQuotaManager { + private static final long GIB = 1024L * 1024 * 1024; + private final Path root; + + public EvidenceQuotaManager(Path stateRoot) throws IOException { + root = stateRoot.toAbsolutePath().normalize(); + Files.createDirectories(root.resolve("quota")); + } + + public synchronized void requireReservation(Bucket bucket, long additionalBytes) throws IOException { + if (additionalBytes < 0) throw new IllegalArgumentException("Quota reservation is invalid"); + var used = directoryBytes(bucket.path(root)); + var reserved = reservedBytes(bucket); + if (used > bucket.limitBytes || reserved > bucket.limitBytes - used + || additionalBytes > bucket.limitBytes - used - reserved) { + throw new IllegalArgumentException("Evidence " + bucket.wire + " quota is exhausted"); + } + } + + public synchronized Reservation reserve(Bucket bucket, String id, long bytes) throws IOException { + if (bucket == Bucket.RESERVE) throw new IllegalArgumentException("Evidence reserve is untouchable"); + if (id == null || !id.matches("[A-Za-z0-9._-]{1,120}")) { + throw new IllegalArgumentException("Quota reservation id is invalid"); + } + requireReservation(bucket, bytes); + var directory = root.resolve("quota/reservations").resolve(bucket.wire); + Files.createDirectories(directory); + var target = directory.resolve(id + ".reservation"); + if (Files.exists(target)) throw new IllegalArgumentException("Quota reservation already exists"); + var partial = target.resolveSibling(target.getFileName() + ".partial"); + Files.writeString(partial, Long.toString(bytes), java.nio.file.StandardOpenOption.CREATE_NEW); + try { + Files.move(partial, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(partial, target); + } + return new Reservation(target, bytes); + } + + public Snapshot snapshot() throws IOException { + var values = new java.util.LinkedHashMap(); + for (var bucket : Bucket.values()) { + values.put(bucket.wire, new Usage(directoryBytes(bucket.path(root)), reservedBytes(bucket), + bucket.limitBytes, bucket == Bucket.RESERVE)); + } + return new Snapshot(java.util.Map.copyOf(values)); + } + + private static long directoryBytes(Path path) throws IOException { + if (!Files.exists(path)) return 0; + try (var paths = Files.walk(path)) { + return paths.filter(Files::isRegularFile).mapToLong(item -> { + try { return Files.size(item); } catch (IOException error) { return Long.MAX_VALUE; } + }).sum(); + } + } + + private long reservedBytes(Bucket bucket) throws IOException { + var directory = root.resolve("quota/reservations").resolve(bucket.wire); + if (!Files.isDirectory(directory)) return 0; + try (var paths = Files.list(directory)) { + return paths.filter(path -> path.getFileName().toString().endsWith(".reservation")) + .mapToLong(path -> { + try { return Long.parseLong(Files.readString(path).trim()); } + catch (Exception error) { return Long.MAX_VALUE; } + }).sum(); + } + } + + public enum Bucket { + SOURCE("source", 45 * GIB, "sources"), + DERIVED("derived", 25 * GIB, "derived"), + MODELS("models", 10 * GIB, "models"), + EVIDENCE_TEST("evidence-test", 10 * GIB, "artifacts"), + RESERVE("reserve", 10 * GIB, "reserve"); + + private final String wire; + private final long limitBytes; + private final String directory; + Bucket(String wire, long limitBytes, String directory) { + this.wire = wire; this.limitBytes = limitBytes; this.directory = directory; + } + Path path(Path root) { return root.resolve(directory); } + } + + public record Usage(long usedBytes, long reservedBytes, long limitBytes, boolean untouchable) {} + public record Snapshot(java.util.Map buckets) {} + + public static final class Reservation implements AutoCloseable { + private final Path path; + private final long bytes; + private Reservation(Path path, long bytes) { this.path = path; this.bytes = bytes; } + public long bytes() { return bytes; } + public void commit() throws IOException { Files.deleteIfExists(path); } + @Override public void close() throws IOException { Files.deleteIfExists(path); } + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceSampleManifest.java b/src/main/java/org/pathlab/forge/evidence/EvidenceSampleManifest.java new file mode 100644 index 0000000..bfc5706 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceSampleManifest.java @@ -0,0 +1,40 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Set; + +/** Provenance and permitted-use gate for one source slide or derived sample. */ +public record EvidenceSampleManifest(String source, String patientGroup, String slideId, + String sha256, String license, String taskLabel, String permittedUse, + long bytes, boolean grandfatheredReadOnly) { + public static final String SCHEMA = "pathlab.evidence-sample/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + + public static EvidenceSampleManifest load(Path path) throws IOException { + var value = JSON.readTree(path.toFile()); + var fields = new java.util.HashSet(); value.fieldNames().forEachRemaining(fields::add); + require(fields.equals(Set.of("schema", "source", "patientGroup", "slideId", "sha256", + "license", "taskLabel", "permittedUse", "bytes", "grandfatheredReadOnly")), + "Evidence sample fields are invalid"); + require(SCHEMA.equals(value.path("schema").asText()), "Evidence sample schema is invalid"); + var result = new EvidenceSampleManifest(text(value, "source", 1000), text(value, "patientGroup", 160), + text(value, "slideId", 160), text(value, "sha256", 64), text(value, "license", 240), + text(value, "taskLabel", 160), text(value, "permittedUse", 80), + value.path("bytes").longValue(), value.path("grandfatheredReadOnly").asBoolean(false)); + require(result.sha256.matches("[a-f0-9]{64}") && result.bytes >= 0 + && Set.of("private-research", "benchmark-only").contains(result.permittedUse), + "Evidence sample provenance is invalid"); + return result; + } + + private static String text(com.fasterxml.jackson.databind.JsonNode value, String field, int max) { + var result = value.path(field).asText(); + require(!result.isBlank() && result.length() <= max, "Evidence sample provenance is invalid"); + return result; + } + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceSetWriter.java b/src/main/java/org/pathlab/forge/evidence/EvidenceSetWriter.java new file mode 100644 index 0000000..074b79e --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceSetWriter.java @@ -0,0 +1,94 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.HashSet; +import java.util.Map; + +/** Fuses reviewed pack outputs only at evidence/provenance/QC/coordinate level. */ +public final class EvidenceSetWriter { + public static final String SCHEMA = "pathlab.evidence-set/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private final Path stateRoot; + private final EvidenceSigner signer; + private final TrustedSignerRegistry registry; + + public EvidenceSetWriter(Path stateRoot) { + this.stateRoot = stateRoot.toAbsolutePath().normalize(); + signer = new EvidenceSigner(this.stateRoot.resolve("signing")); + registry = new TrustedSignerRegistry(this.stateRoot); + } + + public EvidenceBundleWriter.Result write(Path output, String setId, + java.util.List evidenceBundles, Instant now) throws IOException { + require(setId.matches("[A-Za-z0-9._-]{1,160}"), "Evidence set id is invalid"); + require(evidenceBundles != null && !evidenceBundles.isEmpty() && evidenceBundles.size() <= 16, + "Evidence set bundle count is invalid"); + String slideSha = null; + String revision = null; + var hashes = new HashSet(); + var bundles = JSON.createArrayNode(); + var status = "completed"; + for (var bundlePath : evidenceBundles) { + var path = bundlePath.toAbsolutePath().normalize(); + require(path.startsWith(stateRoot.resolve("artifacts")) && Files.isRegularFile(path), + "Evidence set bundle is outside the artifact root"); + var root = JSON.readTree(path.toFile()); + require(!root.path("qualificationOnly").asBoolean(false), + "Qualification-only artifacts cannot enter an evidence set"); + var currentSha = root.path("source").path("slideSha256").asText(); + var currentRevision = root.path("source").path("revision").asText(); + if (slideSha == null) { slideSha = currentSha; revision = currentRevision; } + require(slideSha.equals(currentSha) && revision.equals(currentRevision), + "Evidence set bundles must bind the same slide revision"); + var manifestSha = root.path("manifestSha256").asText(); + require(manifestSha.matches("[a-f0-9]{64}") && hashes.add(manifestSha), + "Evidence set bundle checksum is invalid or duplicated"); + require(EvidenceArtifactStore.isTrusted(stateRoot, path, slideSha, revision), + "Evidence set bundle signature is not trusted"); + var item = bundles.addObject(); + item.put("manifestSha256", manifestSha); + item.put("packId", root.path("pack").path("id").asText()); + item.put("capability", root.path("pack").path("capability").asText("descriptive-evidence")); + if (!"completed".equals(root.path("status").asText())) status = "partial"; + } + var unsigned = JSON.createObjectNode(); + unsigned.put("schema", SCHEMA); + unsigned.put("setId", setId); + unsigned.set("source", JSON.valueToTree(Map.of("slideSha256", slideSha, "revision", revision))); + unsigned.set("bundles", bundles); + unsigned.set("fusion", JSON.valueToTree(Map.of( + "method", "evidence-coordinate-v1", "coordinateBound", true, + "qcBound", true, "provenanceBound", true, "uncertaintyBound", true, + "serialSectionCellMatching", false))); + unsigned.put("status", status); + unsigned.put("researchOnly", true); + unsigned.put("notDiagnostic", true); + unsigned.put("reviewRequired", true); + unsigned.put("createdAt", now.toString()); + var manifestSha = EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(unsigned)); + var signed = signer.sign(SCHEMA + "\n" + manifestSha); + registry.pin(signed.keyId(), signed.publicKeyDer(), "evidence-set"); + var complete = unsigned.deepCopy(); + complete.put("manifestSha256", manifestSha); + complete.set("signature", JSON.valueToTree(Map.of( + "algorithm", "Ed25519", "keyId", signed.keyId(), "value", signed.signature()))); + var target = output.toAbsolutePath().normalize(); + Files.createDirectories(target.getParent()); + var partial = target.resolveSibling(target.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), complete); + try { Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING); } + return new EvidenceBundleWriter.Result(target, manifestSha, signed.keyId()); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceSigner.java b/src/main/java/org/pathlab/forge/evidence/EvidenceSigner.java new file mode 100644 index 0000000..89eb836 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceSigner.java @@ -0,0 +1,78 @@ +package org.pathlab.forge.evidence; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.HexFormat; + +/** Persistent per-install Ed25519 identity for immutable evidence manifests. */ +public final class EvidenceSigner { + private final Path privateKey; + private final Path publicKey; + + public EvidenceSigner(Path root) { + var normalized = root.toAbsolutePath().normalize(); + privateKey = normalized.resolve("evidence-signing-key.pk8"); + publicKey = normalized.resolve("evidence-signing-key.pub"); + } + + public SignedValue sign(String message) throws IOException { + try { + var pair = loadOrCreate(); + var signer = Signature.getInstance("Ed25519"); + signer.initSign(pair.getPrivate()); + signer.update(message.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var publicBytes = pair.getPublic().getEncoded(); + return new SignedValue( + sha256(publicBytes), + Base64.getUrlEncoder().withoutPadding().encodeToString(publicBytes), + Base64.getUrlEncoder().withoutPadding().encodeToString(signer.sign())); + } catch (GeneralSecurityException error) { + throw new IOException("Ed25519 evidence signing is unavailable", error); + } + } + + private KeyPair loadOrCreate() throws IOException, GeneralSecurityException { + Files.createDirectories(privateKey.getParent()); + var factory = KeyFactory.getInstance("Ed25519"); + if (Files.isRegularFile(privateKey) && Files.isRegularFile(publicKey)) { + return new KeyPair( + factory.generatePublic(new X509EncodedKeySpec(Files.readAllBytes(publicKey))), + factory.generatePrivate(new PKCS8EncodedKeySpec(Files.readAllBytes(privateKey)))); + } + if (Files.exists(privateKey) || Files.exists(publicKey)) { + throw new IOException("Evidence signing keypair is incomplete"); + } + var pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + writePrivate(privateKey, pair.getPrivate().getEncoded()); + writePrivate(publicKey, pair.getPublic().getEncoded()); + return pair; + } + + private static void writePrivate(Path target, byte[] value) throws IOException { + var partial = target.resolveSibling(target.getFileName() + ".partial"); + Files.write(partial, value); + try { + Files.setPosixFilePermissions(partial, + java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); + } catch (UnsupportedOperationException ignored) { + // The Windows per-user data root supplies the ACL boundary. + } + Files.move(partial, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + + private static String sha256(byte[] value) throws GeneralSecurityException { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } + + public record SignedValue(String keyId, String publicKeyDer, String signature) {} +} diff --git a/src/main/java/org/pathlab/forge/evidence/EvidenceTileCacheManifest.java b/src/main/java/org/pathlab/forge/evidence/EvidenceTileCacheManifest.java new file mode 100644 index 0000000..a7b8fad --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/EvidenceTileCacheManifest.java @@ -0,0 +1,184 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import javax.imageio.ImageIO; + +/** Immutable, coordinate-bound RGB input tiles for one exact source revision. */ +public record EvidenceTileCacheManifest( + Path path, + String sha256, + String sourceSha256, + String slideRevision, + int sourceWidth, + int sourceHeight, + Path sampleManifestPath, + String sampleManifestSha256, + int tilePixels, + String encoding, + String preprocessingInput, + EvidenceSampleManifest sample, + List tiles) { + public static final String SCHEMA = "pathlab.tile-cache/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern SHA = Pattern.compile("[a-f0-9]{64}"); + private static final Pattern ID = Pattern.compile("[A-Za-z0-9._-]{1,120}"); + private static final Set ROOT_FIELDS = Set.of( + "schema", "source", "tilePixels", "encoding", "preprocessingInput", "tiles"); + private static final Set SOURCE_FIELDS = Set.of( + "sha256", "slideRevision", "width", "height", "sampleManifest", "sampleManifestSha256"); + private static final Set TILE_FIELDS = Set.of( + "id", "path", "sha256", "x", "y", "width", "height"); + + public static EvidenceTileCacheManifest load(Path manifest, String expectedSha256) throws IOException { + var normalized = manifest.toAbsolutePath().normalize(); + require(Files.isRegularFile(normalized), "Tile-cache manifest is unavailable"); + var bytes = Files.readAllBytes(normalized); + var actualManifestSha = sha256(bytes); + require(SHA.matcher(expectedSha256).matches() && expectedSha256.equals(actualManifestSha), + "Tile-cache manifest checksum does not match"); + var root = JSON.readTree(bytes); + require(root.isObject() && fields(root).equals(ROOT_FIELDS), "Tile-cache fields are invalid"); + require(SCHEMA.equals(text(root, "schema", 80)), "Tile-cache schema is unsupported"); + var source = root.path("source"); + require(source.isObject() && fields(source).equals(SOURCE_FIELDS), "Tile-cache source is invalid"); + var sourceSha = text(source, "sha256", 64); + require(SHA.matcher(sourceSha).matches(), "Tile-cache source checksum is invalid"); + var revision = text(source, "slideRevision", 200); + var sourceWidth = positiveInt(source, "width"); + var sourceHeight = positiveInt(source, "height"); + var tilePixels = positiveInt(root, "tilePixels"); + require(tilePixels == 512, "Evidence tile cache must contain 512-pixel tiles"); + var encoding = text(root, "encoding", 20); + require("png".equals(encoding), "Evidence tile cache must use deterministic PNG tiles"); + var preprocessingInput = text(root, "preprocessingInput", 80); + require("rgb-srgb-uint8".equals(preprocessingInput), "Evidence tile input encoding is unsupported"); + + var rootDirectory = normalized.getParent().toRealPath(); + var samplePath = resolveEntry(rootDirectory, text(source, "sampleManifest", 500)); + var expectedSampleSha = text(source, "sampleManifestSha256", 64); + require(SHA.matcher(expectedSampleSha).matches() && expectedSampleSha.equals(sha256(samplePath)), + "Evidence sample manifest checksum does not match"); + var sample = EvidenceSampleManifest.load(samplePath); + require(sourceSha.equals(sample.sha256()), "Tile-cache and sample source checksums differ"); + + var tileNodes = root.path("tiles"); + require(tileNodes.isArray() && !tileNodes.isEmpty() && tileNodes.size() <= 512, + "Evidence tile list is invalid"); + var ids = new HashSet(); + var coordinates = new HashSet(); + var tiles = new ArrayList(); + for (var node : tileNodes) { + require(node.isObject() && fields(node).equals(TILE_FIELDS), "Evidence tile fields are invalid"); + var id = text(node, "id", 120); + require(ID.matcher(id).matches() && ids.add(id), "Evidence tile id is invalid or duplicated"); + var path = resolveEntry(rootDirectory, text(node, "path", 500)); + var expectedTileSha = text(node, "sha256", 64); + require(SHA.matcher(expectedTileSha).matches() && expectedTileSha.equals(sha256(path)), + "Evidence tile checksum does not match"); + var x = nonnegativeInt(node, "x"); + var y = nonnegativeInt(node, "y"); + var width = positiveInt(node, "width"); + var height = positiveInt(node, "height"); + require(width == tilePixels && height == tilePixels + && (long) x + width <= sourceWidth && (long) y + height <= sourceHeight + && coordinates.add(x + ":" + y), + "Evidence tile coordinates are invalid or duplicated"); + BufferedImage image; + try { + image = ImageIO.read(path.toFile()); + } catch (javax.imageio.IIOException invalidImage) { + throw new IllegalArgumentException("Evidence tile could not be decoded", invalidImage); + } + require(image != null && image.getWidth() == tilePixels && image.getHeight() == tilePixels + && image.getColorModel().getNumColorComponents() == 3, + "Evidence tile pixels are invalid"); + tiles.add(new Tile(id, path, expectedTileSha, x, y, width, height)); + } + return new EvidenceTileCacheManifest(normalized, actualManifestSha, sourceSha, revision, + sourceWidth, sourceHeight, samplePath, expectedSampleSha, tilePixels, encoding, + preprocessingInput, sample, List.copyOf(tiles)); + } + + public void requireSource(String sha, String revision, int width, int height, int requiredTilePixels) { + require(sourceSha256.equals(sha) && slideRevision.equals(revision) + && sourceWidth == width && sourceHeight == height && tilePixels == requiredTilePixels, + "Tile cache is stale or belongs to another source revision"); + } + + private static Path resolveEntry(Path root, String relative) throws IOException { + var candidate = Path.of(relative); + require(!candidate.isAbsolute(), "Tile-cache entries must use relative paths"); + var resolved = root.resolve(candidate).normalize(); + require(resolved.startsWith(root) && Files.isRegularFile(resolved), + "Tile-cache entry escaped its immutable root or is unavailable"); + var real = resolved.toRealPath(); + require(real.startsWith(root), "Tile-cache entry escaped its immutable root"); + return real; + } + + private static Set fields(JsonNode node) { + var result = new HashSet(); + node.fieldNames().forEachRemaining(result::add); + return result; + } + + private static int positiveInt(JsonNode node, String field) { + var value = node.path(field); + require(value.isIntegralNumber() && value.canConvertToInt() && value.intValue() > 0, + "Evidence tile geometry is invalid"); + return value.intValue(); + } + + private static int nonnegativeInt(JsonNode node, String field) { + var value = node.path(field); + require(value.isIntegralNumber() && value.canConvertToInt() && value.intValue() >= 0, + "Evidence tile coordinates are invalid"); + return value.intValue(); + } + + private static String text(JsonNode node, String field, int maximum) { + var value = node.path(field); + require(value.isTextual() && !value.textValue().isBlank() && value.textValue().length() <= maximum, + "Evidence tile field is invalid: " + field); + return value.textValue(); + } + + private static String sha256(Path path) throws IOException { + try (InputStream input = Files.newInputStream(path)) { + var digest = MessageDigest.getInstance("SHA-256"); + var buffer = new byte[1024 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read); + return HexFormat.of().formatHex(digest.digest()); + } catch (java.security.GeneralSecurityException impossible) { + throw new IOException("SHA-256 is unavailable", impossible); + } + } + + private static String sha256(byte[] bytes) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (java.security.GeneralSecurityException impossible) { + throw new IOException("SHA-256 is unavailable", impossible); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + public record Tile(String id, Path path, String sha256, int x, int y, int width, int height) {} +} diff --git a/src/main/java/org/pathlab/forge/evidence/ExternalModelWorker.java b/src/main/java/org/pathlab/forge/evidence/ExternalModelWorker.java new file mode 100644 index 0000000..b971e5a --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/ExternalModelWorker.java @@ -0,0 +1,490 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.pathlab.forge.runtime.ChildProcessContainment; + +/** Executes an installed, checksum-pinned model worker through a bounded file protocol. */ +public final class ExternalModelWorker { + public static final String RESULT_SCHEMA = "pathlab.model-worker-result/1"; + public static final String PROGRESS_SCHEMA = "pathlab.model-worker-progress/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set STATUSES = Set.of("completed", "unsupported", "not_evaluable"); + private final Path stateRoot; + + public ExternalModelWorker(Path stateRoot) { + this.stateRoot = stateRoot.toAbsolutePath().normalize(); + } + + public JsonNode execute(EvidencePackManifest pack, Path request, String jobId) throws IOException { + return execute(pack, request, jobId, null, progress -> { }); + } + + public JsonNode execute(EvidencePackManifest pack, Path request, String jobId, + ProgressListener listener) throws IOException { + return execute(pack, request, jobId, null, listener); + } + + public JsonNode execute(EvidencePackManifest pack, Path request, String jobId, Path resumeCheckpoint, + ProgressListener listener) throws IOException { + var runtime = pack.runtimeCompatibility(); + if (!runtime.requiresExternalWorker()) { + throw new IllegalArgumentException("AI pack does not declare an external model worker"); + } + if ("cuda".equals(runtime.executionProvider()) + && (!"12.6".equals(runtime.cuda()) || !"sm_61".equals(runtime.gpuArchitecture()))) { + throw new IllegalArgumentException("Model worker is incompatible with the Pascal qualification host"); + } + var install = stateRoot.resolve("models").resolve(pack.packId()).resolve(pack.version()).normalize(); + require(install.startsWith(stateRoot.resolve("models")), "Model worker path escaped state root"); + var command = new java.util.ArrayList(); + var runtimeReferencePath = install.resolve("runtime-reference.json"); + if (Files.isRegularFile(runtimeReferencePath)) { + verifyArtifact(pack, "runtime-reference", runtimeReferencePath); + verifyArtifact(pack, "workerSource", install.resolve("worker.py")); + var shared = resolveSharedRuntime(stateRoot, install, JSON.readTree(runtimeReferencePath.toFile())); + command.add(shared.python().toString()); + command.add(install.resolve("worker.py").toString()); + } else { + var executable = install.resolve(System.getProperty("os.name", "").startsWith("Windows") + ? "worker.exe" : "worker"); + require(Files.isRegularFile(executable), "Model worker artifact is not installed"); + var expected = pack.artifacts().stream().filter(item -> "worker".equals(item.name())) + .map(EvidencePackManifest.Artifact::sha256).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Model worker checksum is not declared")); + require(expected.equals(sha256(executable)), "Model worker checksum does not match"); + verifyArtifact(pack, "workerSource", install.resolve("worker.py")); + var runtimeManifestPath = install.resolve("runtime-manifest.json"); + verifyArtifact(pack, "runtime-manifest", runtimeManifestPath); + validateRuntimeFileLedger(JSON.readTree(runtimeManifestPath.toFile()), install); + command.add(executable.toString()); + } + + var outputRoot = stateRoot.resolve("worker-output").resolve(jobId).normalize(); + require(outputRoot.startsWith(stateRoot.resolve("worker-output")), "Model output path escaped state root"); + Files.createDirectories(outputRoot); + var output = outputRoot.resolve("result.json"); + var partial = outputRoot.resolve("result.json.partial"); + var progressPath = outputRoot.resolve("progress.json"); + var diagnosticPath = outputRoot.resolve("diagnostic.log"); + Files.deleteIfExists(partial); + Files.deleteIfExists(diagnosticPath); + command.add("--request"); command.add(request.toString()); + command.add("--output"); command.add(partial.toString()); command.add("--offline"); + if (resumeCheckpoint != null) { + require("pathlab.model-worker/2".equals(runtime.workerProtocol()), + "Model worker checkpoint resume is unsupported"); + command.add("--resume-checkpoint"); command.add(resumeCheckpoint.toString()); + } + var builder = new ProcessBuilder(command).directory(install.toFile()); + var environment = builder.environment(); + environment.remove("HTTP_PROXY"); environment.remove("HTTPS_PROXY"); + environment.remove("ALL_PROXY"); environment.remove("HF_TOKEN"); + environment.put("HF_HUB_OFFLINE", "1"); + environment.put("TRANSFORMERS_OFFLINE", "1"); + environment.put("PATHLAB_ANALYSIS_NETWORK", "disabled"); + environment.put("PATHLAB_MAX_VRAM_MIB", Integer.toString(pack.maxVramMiB())); + environment.put("PATHLAB_MAX_RAM_MIB", Integer.toString(pack.maxRamMiB())); + environment.put("CUBLAS_WORKSPACE_CONFIG", ":4096:8"); + var process = ChildProcessContainment.global().register(builder.redirectErrorStream(true).start()); + var diagnosticBytes = new ByteArrayOutputStream(16_384); + var outputReader = new Thread(() -> drainBounded(process.getInputStream(), diagnosticBytes), + "pathlab-model-worker-output-" + jobId); + outputReader.setDaemon(true); + outputReader.start(); + var lastProgress = new Progress(0, 0, null, ""); + try { + var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(pack.maxSeconds()); + var lastProgressModified = -1L; + var lastHeartbeat = 0L; + while (!process.waitFor(1, TimeUnit.SECONDS)) { + if (System.nanoTime() >= deadline) { + process.destroyForcibly(); + throw new ResourceLimitException("Model worker exceeded its bounded runtime"); + } + if (Files.isRegularFile(progressPath)) { + var modified = Files.getLastModifiedTime(progressPath).toMillis(); + if (modified != lastProgressModified) { + lastProgress = readProgress(progressPath, jobId, pack); + listener.update(lastProgress); + lastProgressModified = modified; + lastHeartbeat = System.nanoTime(); + continue; + } + } + if (System.nanoTime() - lastHeartbeat >= TimeUnit.SECONDS.toNanos(10)) { + listener.update(lastProgress); + lastHeartbeat = System.nanoTime(); + } + } + } catch (IOException error) { + process.destroyForcibly(); + awaitOutput(outputReader, process.getInputStream()); + Files.deleteIfExists(partial); + throw error; + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); process.destroyForcibly(); + awaitOutput(outputReader, process.getInputStream()); + Files.deleteIfExists(partial); + throw new IOException("Model worker was interrupted", error); + } + awaitOutput(outputReader, process.getInputStream()); + if (process.exitValue() != 0 || !Files.isRegularFile(partial)) { + Files.deleteIfExists(partial); + writeDiagnostic(diagnosticPath, diagnosticBytes.toByteArray()); + var detail = safeFailureDetail(diagnosticBytes.toString(StandardCharsets.UTF_8)); + throw new IllegalArgumentException(detail.isBlank() + ? "Model worker failed closed with exit code " + process.exitValue() + : "Model worker failed closed with exit code " + process.exitValue() + ": " + detail); + } + var result = JSON.readTree(partial.toFile()); + validateResult(result, pack); + notifyFinalProgress(progressPath, jobId, pack, lastProgress, listener); + try { + Files.move(partial, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(partial, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return result; + } + + static Progress notifyFinalProgress(Path progressPath, String jobId, EvidencePackManifest pack, + Progress observed, ProgressListener listener) throws IOException { + if (!Files.isRegularFile(progressPath)) return observed; + var latest = readProgress(progressPath, jobId, pack); + require(latest.completedUnits() >= observed.completedUnits() + && (observed.totalUnits() == 0 || latest.totalUnits() == observed.totalUnits()), + "Model worker final progress is not monotonic"); + if (!latest.equals(observed)) listener.update(latest); + return latest; + } + + private static void writeDiagnostic(Path path, byte[] value) { + if (value.length == 0) return; + try { + var partial = path.resolveSibling(path.getFileName() + ".partial"); + Files.write(partial, value); + try { + Files.move(partial, path, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(partial, path, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException ignored) { + // Failure diagnostics never change durable model job state. + } + } + + private static Progress readProgress(Path path, String jobId, EvidencePackManifest pack) throws IOException { + var value = JSON.readTree(path.toFile()); + require(value.isObject() && fieldNames(value).equals(Set.of("schema", "jobId", "packManifestSha256", + "completedUnits", "totalUnits", "checkpointPath", "checkpointSha256", "updatedAt")), + "Model worker progress fields are invalid"); + require(PROGRESS_SCHEMA.equals(value.path("schema").asText()), "Model worker progress schema is invalid"); + require(jobId.equals(value.path("jobId").asText()) + && pack.sha256().equals(value.path("packManifestSha256").asText()), + "Model worker progress identity does not match"); + var completed = value.path("completedUnits").asLong(-1); + var total = value.path("totalUnits").asLong(-1); + require(completed >= 0 && total >= completed, "Model worker progress counts are invalid"); + var checkpointText = value.path("checkpointPath").asText(); + var checksum = value.path("checkpointSha256").asText(); + Path checkpoint = checkpointText.isBlank() ? null : Path.of(checkpointText).toAbsolutePath().normalize(); + if (checkpoint != null) { + require(Files.isRegularFile(checkpoint) && checksum.matches("[a-f0-9]{64}") + && checksum.equals(sha256(checkpoint)), "Model worker checkpoint is invalid"); + } else require(checksum.isBlank(), "Checkpoint checksum has no checkpoint"); + Instant.parse(value.path("updatedAt").asText()); + return new Progress(completed, total, checkpoint, checksum); + } + + private static Set fieldNames(JsonNode value) { + var names = new java.util.HashSet(); value.fieldNames().forEachRemaining(names::add); return names; + } + + private static void drainBounded(InputStream input, ByteArrayOutputStream output) { + try (input) { + var buffer = new byte[4_096]; + int read; + while ((read = input.read(buffer)) >= 0) { + var remaining = 16_384 - output.size(); + if (remaining > 0) output.write(buffer, 0, Math.min(read, remaining)); + } + } catch (IOException ignored) { + // Diagnostics are best-effort and never alter worker state. + } + } + + private static void awaitOutput(Thread reader, InputStream input) { + try { + reader.join(2_000); + if (reader.isAlive()) input.close(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + // Diagnostics are best-effort and never alter worker state. + } + } + + static String safeFailureDetail(String output) { + if (output == null || output.isBlank()) return ""; + for (var line : output.split("\\R")) { + var prefix = line.startsWith("PathLab model worker failed closed: ") + ? "PathLab model worker failed closed: " : "PathLab DINOv2 worker failed closed: "; + if (!line.startsWith(prefix)) continue; + var detail = line.substring(prefix.length()).trim(); + if (Set.of( + "offline analysis contract was not enforced", + "model library offline flags were not enforced", + "runtime manifest schema is invalid", + "runtime file ledger is invalid", + "CUDA sm_61 host is unavailable", + "runtime does not contain the pinned Pascal CUDA target", + "resource envelope is invalid", + "source geometry is invalid", + "tile-cache manifest checksum does not match", + "tile-cache schema is unsupported", + "tile cache is stale or belongs to another source revision", + "tile-cache pixel contract is unsupported", + "tile-cache tile list is invalid", + "tile-cache tile entry is invalid", + "tile-cache tile path must be relative", + "tile-cache tile checksum or path is invalid", + "tile-cache tile coordinates are invalid or duplicated", + "tile-cache tile geometry is invalid", + "worker exceeded its declared resource envelope", + "pack manifest is unavailable").contains(detail) + || detail.matches("(required artifact is unavailable|artifact checksum mismatch): [A-Za-z0-9_-]{1,80}")) { + return detail; + } + } + return ""; + } + + private static void verifyArtifact(EvidencePackManifest pack, String name, Path path) throws IOException { + var expected = pack.artifacts().stream().filter(item -> name.equals(item.name())) + .map(EvidencePackManifest.Artifact::sha256).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Model worker artifact is not declared: " + name)); + require(Files.isRegularFile(path) && expected.equals(sha256(path)), + "Model worker artifact checksum does not match: " + name); + } + + static void validateRuntimeFileLedger(JsonNode manifest, Path installRoot) throws IOException { + require(manifest.isObject() && "pathlab.model-runtime/1".equals(manifest.path("schema").asText()), + "Model runtime manifest schema is invalid"); + var files = manifest.path("files"); + require(files.isArray() && !files.isEmpty() && files.size() <= 8_192, + "Model runtime file ledger is invalid"); + var root = installRoot.toAbsolutePath().normalize(); + var realRoot = root.toRealPath(); + var seen = new java.util.HashSet(); + for (var item : files) { + var relativeText = item.path("path").asText(""); + require(!relativeText.isBlank() && relativeText.length() <= 500 + && !Path.of(relativeText).isAbsolute() && seen.add(relativeText), + "Model runtime file ledger is invalid"); + var path = root.resolve(relativeText).normalize(); + require(path.startsWith(root) && Files.isRegularFile(path) + && path.toRealPath().startsWith(realRoot) + && item.path("bytes").isIntegralNumber() + && item.path("bytes").asLong(-1) == Files.size(path) + && item.path("sha256").asText("").matches("[a-f0-9]{64}") + && item.path("sha256").asText().equals(sha256(path)), + "Model runtime file checksum does not match"); + } + } + + static SharedRuntime resolveSharedRuntime(Path stateRoot, Path installRoot, JsonNode reference) + throws IOException { + require(reference.isObject() && fieldNames(reference).equals(Set.of( + "schema", "sharedRuntimePack", "sharedRuntimeVersion", "sharedRuntimeRoot", + "sharedRuntimeManifestSha256", "pythonRelativePath", "candidateId", + "candidateVersion", "candidateRoot", "candidateLedgerSha256", "weightFile", + "weightSha256", "runtimeCopiedIntoCandidate", "analysisNetwork")), + "Model shared-runtime reference fields are invalid"); + require("pathlab.model-runtime-reference/1".equals(reference.path("schema").asText()) + && !reference.path("runtimeCopiedIntoCandidate").asBoolean(true) + && "disabled".equals(reference.path("analysisNetwork").asText()), + "Model shared-runtime reference policy is invalid"); + var state = stateRoot.toAbsolutePath().normalize(); + var models = state.resolve("models").normalize(); + var realModels = models.toRealPath(); + require(installRoot.toAbsolutePath().normalize().startsWith(models), + "Model install root escaped state root"); + var runtimePack = reference.path("sharedRuntimePack").asText(); + var runtimeVersion = reference.path("sharedRuntimeVersion").asText(); + var runtimeRoot = Path.of(reference.path("sharedRuntimeRoot").asText()).toAbsolutePath().normalize(); + require(runtimeRoot.equals(models.resolve(runtimePack).resolve(runtimeVersion).normalize()), + "Model shared-runtime identity is invalid"); + require(runtimeRoot.toRealPath().startsWith(realModels), "Model shared-runtime path escaped state root"); + var manifest = runtimeRoot.resolve("runtime-manifest.json"); + require(Files.isRegularFile(manifest) + && reference.path("sharedRuntimeManifestSha256").asText().equals(sha256(manifest)), + "Model shared-runtime manifest checksum changed"); + validateRuntimeFileLedger(JSON.readTree(manifest.toFile()), runtimeRoot); + var pythonRelative = Path.of(reference.path("pythonRelativePath").asText()); + var python = runtimeRoot.resolve(pythonRelative).normalize(); + require(!pythonRelative.isAbsolute() && python.startsWith(runtimeRoot) && Files.isRegularFile(python) + && python.toRealPath().startsWith(runtimeRoot.toRealPath()), + "Model shared Python executable is invalid"); + + var candidateId = reference.path("candidateId").asText(); + var candidateVersion = reference.path("candidateVersion").asText(); + var candidate = Path.of(reference.path("candidateRoot").asText()).toAbsolutePath().normalize(); + require(candidate.equals(models.resolve(candidateId).resolve(candidateVersion).normalize()), + "Model candidate identity is invalid"); + require(candidate.toRealPath().startsWith(realModels), "Model candidate path escaped state root"); + var ledger = candidate.resolve("candidate-ledger.json"); + require(Files.isRegularFile(ledger) + && reference.path("candidateLedgerSha256").asText().equals(sha256(ledger)), + "Model candidate ledger checksum changed"); + var weightRelative = Path.of(reference.path("weightFile").asText()); + var weight = candidate.resolve(weightRelative).normalize(); + require(!weightRelative.isAbsolute() && weight.startsWith(candidate) && Files.isRegularFile(weight) + && weight.toRealPath().startsWith(candidate.toRealPath()) + && reference.path("weightSha256").asText().equals(sha256(weight)), + "Model candidate weight checksum changed"); + return new SharedRuntime(runtimeRoot, python); + } + + @FunctionalInterface + public interface ProgressListener { void update(Progress progress) throws IOException; } + public record Progress(long completedUnits, long totalUnits, Path checkpointPath, String checkpointSha256) { } + record SharedRuntime(Path root, Path python) { } + public static final class ResourceLimitException extends IOException { + private static final long serialVersionUID = 1L; + ResourceLimitException(String message) { super(message); } + } + + static void validateResult(JsonNode result, EvidencePackManifest pack) { + require(result.isObject() && RESULT_SCHEMA.equals(result.path("schema").asText()), + "Model worker result schema is invalid"); + require(pack.sha256().equals(result.path("packManifestSha256").asText()), + "Model worker result pack identity does not match"); + require(STATUSES.contains(result.path("status").asText()), "Model worker result status is invalid"); + var regions = result.path("regions"); + require(regions.isArray() && regions.size() <= 1_000, "Model worker regions are invalid"); + for (var region : regions) { + require(region.isObject() && region.path("id").asText().matches("[A-Za-z0-9._-]{1,120}") + && Set.of("coarse", "refined").contains(region.path("stage").asText()) + && Set.of("support", "similar", "contrast").contains(region.path("kind").asText()) + && finiteNonnegative(region, "x") && finiteNonnegative(region, "y") + && finitePositive(region, "width") && finitePositive(region, "height") + && finiteUnit(region, "score"), + "Model worker region is invalid"); + } + if (result.has("qualificationMetrics")) validateQualificationMetrics(result.path("qualificationMetrics")); + var serialized = result.toString(); + require(!serialized.matches("(?is).*\"(embedding|embeddings|rawPixels|diagnosis|clinicalScore|tps|cps|treatment)\".*"), + "Model worker emitted prohibited reusable or clinical output"); + } + + private static void validateQualificationMetrics(JsonNode metrics) { + if ("pathlab.cell-instance-metrics/1".equals(metrics.path("schema").asText())) { + validateCellQualificationMetrics(metrics); + return; + } + require(metrics.isObject() + && "pathlab.he-retrieval-metrics/1".equals(metrics.path("schema").asText()) + && metrics.path("cohortManifestSha256").asText().matches("[a-f0-9]{64}") + && metrics.path("sampleCount").asInt(-1) >= 40 + && metrics.path("referenceCount").asInt(-1) >= 20 + && metrics.path("queryCount").asInt(-1) >= 20 + && metrics.path("oodCount").asInt(-1) >= 0 + && metrics.path("evaluationGroups").isArray() + && metrics.path("exactRankingRepeatability").isBoolean() + && metrics.path("embeddingsExported").isBoolean() + && !metrics.path("embeddingsExported").asBoolean(true) + && finite(metrics, "baselineMacroRecallAt5") + && finite(metrics, "modelMacroRecallAt5") + && finite(metrics, "macroRecallAt5Improvement") + && finite(metrics, "baselineMacroNdcgAt10") + && finite(metrics, "modelMacroNdcgAt10") + && finite(metrics, "macroNdcgAt10Improvement") + && metrics.path("notEvaluableReasons").isArray(), + "Model worker qualification metrics are invalid"); + } + + private static void validateCellQualificationMetrics(JsonNode metrics) { + var sampleFailures = metrics.path("sampleFailures"); + require(metrics.isObject() + && metrics.path("cohortManifestSha256").asText().matches("[a-f0-9]{64}") + && metrics.path("sampleCount").asInt(-1) >= 4 + && metrics.path("evaluatedSampleCount").asInt(-1) >= 1 + && metrics.path("evaluatedSampleCount").asInt() <= metrics.path("sampleCount").asInt() + && finite(metrics, "macroPq") && finite(metrics, "instanceDice") + && finite(metrics, "countError") && finite(metrics, "morphometryBias") + && finite(metrics, "failedRegionRate") && finite(metrics, "elapsedSeconds") + && finite(metrics, "peakHeapMiB") + && finite(metrics, "minimumMacroPq") && finite(metrics, "minimumInstanceDice") + && finite(metrics, "maximumCountError") && finite(metrics, "maximumMorphometryBias") + && finite(metrics, "maximumFailedRegionRate") + && metrics.path("deterministicRepeat").isBoolean() + && metrics.path("crossTissuePerformance").isBoolean() + && metrics.path("rightsAndIntegrityPassed").isBoolean() + && metrics.path("resourceCompliant").isBoolean() + && metrics.path("upstreamChecksumAvailable").isBoolean() + && metrics.path("sourceIntegrity").isTextual() + && metrics.path("perOrgan").isObject() + && sampleFailures.isArray() + && metrics.path("evaluatedSampleCount").asInt() + sampleFailures.size() + == metrics.path("sampleCount").asInt() + && Math.abs(metrics.path("failedRegionRate").asDouble() + - (double) sampleFailures.size() / metrics.path("sampleCount").asInt()) < 1.0e-9 + && metrics.path("notEvaluableReasons").isArray(), + "Model worker cell qualification metrics are invalid"); + var failureCodes = Set.of("SAMPLE_RIGHTS_OR_SPLIT_INVALID", "SAMPLE_CHECKSUM_CHANGED", + "SAMPLE_GEOMETRY_INVALID", "SAMPLE_ANNOTATION_INVALID", + "MODEL_INFERENCE_FAILED", "SAMPLE_EVALUATION_FAILED"); + for (var failure : sampleFailures) { + require(failure.isObject() && fieldNames(failure).equals(Set.of("sampleId", "organ", "code")) + && failure.path("sampleId").asText().matches("[A-Za-z0-9._-]{1,120}") + && Set.of("lung", "kidney", "breast", "prostate", "unknown") + .contains(failure.path("organ").asText()) + && failureCodes.contains(failure.path("code").asText()), + "Model worker cell sample failure is invalid"); + } + } + + private static boolean finite(JsonNode node, String field) { + return node.path(field).isNumber() && Double.isFinite(node.path(field).doubleValue()); + } + + private static String sha256(Path path) throws IOException { + try (var input = Files.newInputStream(path)) { + var digest = MessageDigest.getInstance("SHA-256"); + var buffer = new byte[1024 * 1024]; + int read; while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read); + return HexFormat.of().formatHex(digest.digest()); + } catch (java.security.GeneralSecurityException error) { + throw new IOException("SHA-256 is unavailable", error); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + private static boolean finiteNonnegative(JsonNode node, String field) { + return node.path(field).isNumber() && Double.isFinite(node.path(field).doubleValue()) + && node.path(field).doubleValue() >= 0; + } + private static boolean finitePositive(JsonNode node, String field) { + return finiteNonnegative(node, field) && node.path(field).doubleValue() > 0; + } + private static boolean finiteUnit(JsonNode node, String field) { + return finiteNonnegative(node, field) && node.path(field).doubleValue() <= 1; + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/IhcMeasurementAnalyzer.java b/src/main/java/org/pathlab/forge/evidence/IhcMeasurementAnalyzer.java new file mode 100644 index 0000000..6931664 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/IhcMeasurementAnalyzer.java @@ -0,0 +1,221 @@ +package org.pathlab.forge.evidence; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Deterministic within-slide H/DAB descriptors; never emits clinical categories or scores. */ +public final class IhcMeasurementAnalyzer { + private static final Set NUCLEAR = Set.of("er", "pr", "ki-67"); + private IhcMeasurementAnalyzer() { } + + public static Result analyze(BufferedImage image, String requestedMarker, + List reviewedRegions, boolean controlsValidated) { + var marker = requestedMarker == null ? "generic" : requestedMarker.toLowerCase(); + if (!Set.of("generic", "er", "pr", "ki-67", "her2", "pd-l1").contains(marker)) marker = "generic"; + var qc = BrightfieldStainQc.inspect(image, controlsValidated); + var cells = OpticalDensityWatershed.segment(image).instances(); + if ("pd-l1".equals(marker)) { + var approved = reviewedRegions == null ? List.of() : reviewedRegions.stream() + .filter(region -> Set.of("faculty-authored", "faculty-approved").contains(region.reviewSource())) + .filter(region -> Set.of("tumor", "immune").contains(region.kind())) + .toList(); + if (approved.isEmpty()) { + return new Result(marker, "generic-fallback", "generic-region", qc.calibrationStatus(), + generic(image, cells, null), "COMPARTMENT_REVIEW_REQUIRED", qc.reasons(), 1 - qc.separationScore()); + } + var compartments = new ArrayList(); + for (var region : approved) { + compartments.add(new Compartment(region.id(), region.kind() + "-region", + generic(image, cells, region), region.reviewSource())); + } + return new Result(marker, "marker-aware", "tumor-immune-region", qc.calibrationStatus(), + Map.of("compartments", List.copyOf(compartments)), null, qc.reasons(), 1 - qc.separationScore()); + } + if (NUCLEAR.contains(marker)) { + return new Result(marker, "marker-aware", "nuclear", qc.calibrationStatus(), + nuclear(image, cells), null, qc.reasons(), 1 - qc.separationScore()); + } + if ("her2".equals(marker)) { + return new Result(marker, "marker-aware", "membrane", qc.calibrationStatus(), + membrane(image, cells), null, qc.reasons(), 1 - qc.separationScore()); + } + return new Result("generic", "generic-descriptive", "generic-region", qc.calibrationStatus(), + generic(image, cells, null), null, qc.reasons(), 1 - qc.separationScore()); + } + + private static Map nuclear(BufferedImage image, + List cells) { + var low = 0; + var moderate = 0; + var strong = 0; + var unstained = 0; + double totalOd = 0; + for (var cell : cells) { + var od = meanDabOd(image, cell.rle(), null); + totalOd += od; + if (od >= 0.45) strong++; + else if (od >= 0.25) moderate++; + else if (od >= 0.10) low++; + else unstained++; + } + var stained = low + moderate + strong; + var values = new LinkedHashMap(); + values.put("totalNuclei", cells.size()); + values.put("dabAssociatedNuclei", stained); + values.put("dabAssociatedFraction", cells.isEmpty() ? 0 : (double) stained / cells.size()); + values.put("meanNuclearDabOd", cells.isEmpty() ? 0 : totalOd / cells.size()); + values.put("intensityDistribution", Map.of( + "unstained", unstained, "low", low, "moderate", moderate, "strong", strong)); + return Map.copyOf(values); + } + + private static Map membrane(BufferedImage image, + List cells) { + double completeness = 0; + double continuity = 0; + double intensity = 0; + for (var cell : cells) { + var ring = ringPixels(cell.rle(), image.getWidth(), image.getHeight()); + var brown = 0; + for (var pixel : ring) if (isDab(image.getRGB(pixel % image.getWidth(), pixel / image.getWidth()))) brown++; + var fraction = ring.isEmpty() ? 0 : (double) brown / ring.size(); + completeness += fraction; + continuity += longestCircularRun(ring, image) / Math.max(1.0, ring.size()); + intensity += meanDabOd(image, ring.stream().flatMapToInt(pixel -> java.util.stream.IntStream.of(pixel, 1)).boxed().toList(), null); + } + var count = Math.max(1, cells.size()); + return Map.of( + "evaluatedCellMasks", cells.size(), + "meanMembraneCompleteness", completeness / count, + "meanMembraneContinuity", continuity / count, + "meanMembraneDabOd", intensity / count); + } + + private static Map generic(BufferedImage image, + List cells, ReviewedRegion region) { + var bounds = bounds(region, image.getWidth(), image.getHeight()); + var dab = 0; + var tissue = 0; + double od = 0; + for (var y = bounds[1]; y < bounds[3]; y++) for (var x = bounds[0]; x < bounds[2]; x++) { + var rgb = image.getRGB(x, y); + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + if ((red + green + blue) / 3.0 < 235) tissue++; + if (isDab(rgb)) { + dab++; + od += dabOd(rgb); + } + } + var associated = cells.stream().filter(cell -> inRegion(cell.centroidX(), cell.centroidY(), bounds)) + .filter(cell -> meanDabOd(image, cell.rle(), bounds) >= 0.10).count(); + return Map.of( + "dabAreaFraction", tissue == 0 ? 0 : (double) dab / tissue, + "meanDabOd", dab == 0 ? 0 : od / dab, + "cellCount", cells.stream().filter(cell -> inRegion(cell.centroidX(), cell.centroidY(), bounds)).count(), + "dabAssociatedCellCount", associated); + } + + private static int[] bounds(ReviewedRegion region, int width, int height) { + if (region == null) return new int[] {0, 0, width, height}; + var x0 = Math.max(0, Math.min(width, region.x())); + var y0 = Math.max(0, Math.min(height, region.y())); + var x1 = Math.max(x0, Math.min(width, region.x() + region.width())); + var y1 = Math.max(y0, Math.min(height, region.y() + region.height())); + return new int[] {x0, y0, x1, y1}; + } + + private static boolean inRegion(double x, double y, int[] bounds) { + return x >= bounds[0] && x < bounds[2] && y >= bounds[1] && y < bounds[3]; + } + + private static double meanDabOd(BufferedImage image, List rle, int[] bounds) { + double total = 0; + var count = 0; + for (var index = 0; index + 1 < rle.size(); index += 2) { + var start = rle.get(index); + var length = rle.get(index + 1); + for (var offset = 0; offset < length; offset++) { + var pixel = start + offset; + var x = pixel % image.getWidth(); + var y = pixel / image.getWidth(); + if (bounds != null && !inRegion(x, y, bounds)) continue; + var rgb = image.getRGB(x, y); + if (isDab(rgb)) { + total += dabOd(rgb); + count++; + } + } + } + return count == 0 ? 0 : total / count; + } + + private static List ringPixels(List rle, int width, int height) { + var mask = new java.util.HashSet(); + for (var index = 0; index + 1 < rle.size(); index += 2) { + for (var offset = 0; offset < rle.get(index + 1); offset++) mask.add(rle.get(index) + offset); + } + var ring = new java.util.TreeSet(); + for (var pixel : mask) { + var x = pixel % width; + var y = pixel / width; + for (var ny = Math.max(0, y - 1); ny <= Math.min(height - 1, y + 1); ny++) { + for (var nx = Math.max(0, x - 1); nx <= Math.min(width - 1, x + 1); nx++) { + var candidate = ny * width + nx; + if (!mask.contains(candidate)) ring.add(candidate); + } + } + } + return List.copyOf(ring); + } + + private static double longestCircularRun(List ring, BufferedImage image) { + var longest = 0; + var current = 0; + for (var pixel : ring) { + if (isDab(image.getRGB(pixel % image.getWidth(), pixel / image.getWidth()))) { + current++; + longest = Math.max(longest, current); + } else current = 0; + } + return longest; + } + + private static boolean isDab(int rgb) { + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + return red - green >= 15 && green - blue >= 15 && red + green + blue < 660; + } + + private static double dabOd(int rgb) { + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + var luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + return -Math.log((luminance + 1) / 256.0); + } + + public record ReviewedRegion(String id, String kind, String reviewSource, + int x, int y, int width, int height) { + public ReviewedRegion { + if (id == null || !id.matches("[A-Za-z0-9._-]{1,120}") + || !Set.of("tumor", "immune", "analysis").contains(kind) + || !Set.of("faculty-authored", "faculty-approved", "model-suggested").contains(reviewSource) + || x < 0 || y < 0 || width < 1 || height < 1) { + throw new IllegalArgumentException("Reviewed compartment region is invalid"); + } + } + } + + public record Result(String markerId, String analysisMode, String compartment, + String calibrationStatus, Map measurements, String abstentionReason, + List qc, double uncertainty) { } + public record Compartment(String regionId, String compartment, Map measurements, + String reviewSource) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/OpticalDensityWatershed.java b/src/main/java/org/pathlab/forge/evidence/OpticalDensityWatershed.java new file mode 100644 index 0000000..7497388 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/OpticalDensityWatershed.java @@ -0,0 +1,170 @@ +package org.pathlab.forge.evidence; + +import java.awt.image.BufferedImage; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** Deterministic marker-controlled watershed fallback for bounded brightfield regions. */ +public final class OpticalDensityWatershed { + private OpticalDensityWatershed() { } + + public static Result segment(BufferedImage image) { + if (image == null || image.getWidth() < 1 || image.getHeight() < 1 + || (long) image.getWidth() * image.getHeight() > 4_194_304) { + throw new IllegalArgumentException("Brightfield image geometry is invalid"); + } + var width = image.getWidth(); + var height = image.getHeight(); + var mask = new boolean[width * height]; + for (var y = 0; y < height; y++) for (var x = 0; x < width; x++) { + var rgb = image.getRGB(x, y); + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + var opticalDensity = -Math.log((red + 1) / 256.0) + + -Math.log((green + 1) / 256.0) + + -Math.log((blue + 1) / 256.0); + mask[y * width + x] = blue - red >= 25 && blue - green >= 25 + && opticalDensity >= 0.25; + } + var components = connected(mask, width, height); + var instances = new ArrayList(); + for (var component : components) split(component, image, width, height, instances); + instances.sort(Comparator.comparingInt(Instance::firstPixel)); + var numbered = new ArrayList(); + for (var index = 0; index < instances.size(); index++) { + var item = instances.get(index); + numbered.add(new Instance("cell-" + (index + 1), item.firstPixel(), item.areaPx2(), + item.perimeterPx(), item.eccentricity(), item.solidity(), item.meanIntensity(), + item.centroidX(), item.centroidY(), item.rle())); + } + return new Result(List.copyOf(numbered), "od-watershed", true); + } + + private static List connected(boolean[] mask, int width, int height) { + var seen = new boolean[mask.length]; + var queue = new ArrayDeque(); + var result = new ArrayList(); + for (var start = 0; start < mask.length; start++) { + if (!mask[start] || seen[start]) continue; + var pixels = new ArrayList(); + seen[start] = true; + queue.add(start); + while (!queue.isEmpty()) { + var current = queue.removeFirst(); + pixels.add(current); + var x = current % width; + var y = current / width; + if (x > 0) enqueue(current - 1, mask, seen, queue); + if (x + 1 < width) enqueue(current + 1, mask, seen, queue); + if (y > 0) enqueue(current - width, mask, seen, queue); + if (y + 1 < height) enqueue(current + width, mask, seen, queue); + } + if (pixels.size() >= 4) result.add(pixels.stream().mapToInt(Integer::intValue).toArray()); + } + return result; + } + + private static void enqueue(int index, boolean[] mask, boolean[] seen, ArrayDeque queue) { + if (mask[index] && !seen[index]) { + seen[index] = true; + queue.addLast(index); + } + } + + private static void split(int[] component, BufferedImage image, int width, int height, + List output) { + var minX = width; + var minY = height; + var maxX = -1; + var maxY = -1; + for (var pixel : component) { + var x = pixel % width; + var y = pixel / width; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + var boxWidth = maxX - minX + 1; + var boxHeight = maxY - minY + 1; + var major = Math.max(boxWidth, boxHeight); + var minor = Math.max(1, Math.min(boxWidth, boxHeight)); + var seedCount = Math.max(1, Math.min(8, (int) Math.round((double) major / minor))); + if (component.length < seedCount * 4) seedCount = Math.max(1, component.length / 4); + var groups = new ArrayList>(); + for (var index = 0; index < seedCount; index++) groups.add(new ArrayList<>()); + var horizontal = boxWidth >= boxHeight; + for (var pixel : component) { + var coordinate = horizontal ? pixel % width - minX : pixel / width - minY; + var group = Math.min(seedCount - 1, coordinate * seedCount / major); + groups.get(group).add(pixel); + } + // Empty marker basins are merged rather than emitted as fabricated instances. + for (var group : groups) { + if (group.size() >= 4) output.add(measure(group, image, width, height)); + } + } + + private static Instance measure(List pixels, BufferedImage image, int width, int height) { + var included = new java.util.HashSet(pixels); + var minX = width; + var minY = height; + var maxX = -1; + var maxY = -1; + var perimeter = 0; + double xSum = 0; + double ySum = 0; + double intensity = 0; + pixels.sort(Integer::compareTo); + for (var pixel : pixels) { + var x = pixel % width; + var y = pixel / width; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + xSum += x; + ySum += y; + var rgb = image.getRGB(x, y); + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + intensity += 1 - (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0; + if (x == 0 || !included.contains(pixel - 1)) perimeter++; + if (x + 1 == width || !included.contains(pixel + 1)) perimeter++; + if (y == 0 || !included.contains(pixel - width)) perimeter++; + if (y + 1 == height || !included.contains(pixel + width)) perimeter++; + } + var boxWidth = maxX - minX + 1.0; + var boxHeight = maxY - minY + 1.0; + var major = Math.max(boxWidth, boxHeight); + var minor = Math.min(boxWidth, boxHeight); + var eccentricity = major == 0 ? 0 : Math.sqrt(Math.max(0, 1 - (minor * minor) / (major * major))); + var rle = new ArrayList(); + var runStart = pixels.get(0); + var prior = runStart; + for (var index = 1; index < pixels.size(); index++) { + var current = pixels.get(index); + if (current != prior + 1 || current / width != prior / width) { + rle.add(runStart); + rle.add(prior - runStart + 1); + runStart = current; + } + prior = current; + } + rle.add(runStart); + rle.add(prior - runStart + 1); + return new Instance("", pixels.get(0), pixels.size(), perimeter, eccentricity, + pixels.size() / (boxWidth * boxHeight), intensity / pixels.size(), + xSum / pixels.size(), ySum / pixels.size(), List.copyOf(rle)); + } + + public record Result(List instances, String method, boolean deterministic) { } + public record Instance( + String id, int firstPixel, double areaPx2, double perimeterPx, + double eccentricity, double solidity, double meanIntensity, + double centroidX, double centroidY, List rle) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/QualificationCampaignManifest.java b/src/main/java/org/pathlab/forge/evidence/QualificationCampaignManifest.java new file mode 100644 index 0000000..b606f9d --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/QualificationCampaignManifest.java @@ -0,0 +1,172 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +/** Immutable, checksum-bound description of one autonomous qualification campaign. */ +public record QualificationCampaignManifest( + String campaignId, + Path path, + String sha256, + int maxRemediationAttempts, + List tracks) { + public static final String SCHEMA = "pathlab.qualification-campaign/1"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set CAPABILITIES = Set.of( + "he-evidence", "cell-morphology", "ihc-descriptive", + "special-stain-descriptive", "cytology-descriptive", + "grounded-tutor", "atlas-distillation"); + private static final Set SCOPES = Set.of("deployment", "research-restricted", "local-benchmark"); + + public static QualificationCampaignManifest load(Path input) throws IOException { + var path = input.toAbsolutePath().normalize(); + if (!Files.isRegularFile(path) || Files.size(path) > 2L * 1024 * 1024) { + throw new IllegalArgumentException("Qualification campaign manifest is unavailable or too large"); + } + var root = JSON.readTree(path.toFile()); + exact(root, Set.of("schema", "campaignId", "createdAt", "researchOnly", "notDiagnostic", + "maxRemediationAttempts", "quota", "tracks")); + require(SCHEMA.equals(text(root, "schema")), "Qualification campaign schema is unsupported"); + var campaignId = identifier(text(root, "campaignId")); + Instant.parse(text(root, "createdAt")); + require(root.path("researchOnly").asBoolean(false), "Qualification campaign must be research-only"); + require(root.path("notDiagnostic").asBoolean(false), "Qualification campaign must be non-diagnostic"); + var remediation = root.path("maxRemediationAttempts"); + require(remediation.isInt() && remediation.intValue() == 1, + "Qualification campaign requires exactly one bounded remediation attempt"); + validateQuota(root.path("quota")); + var items = root.path("tracks"); + require(items.isArray() && !items.isEmpty() && items.size() <= 64, + "Qualification campaign tracks are invalid"); + var tracks = new ArrayList(); + var ids = new HashSet(); + for (var item : items) { + exact(item, Set.of("id", "candidateId", "capability", "scope", "requestPath", + "remediationRequestPath", "expectedAttestationPath", "protocolSha256", + "dependsOn", "required")); + var id = identifier(text(item, "id")); + require(ids.add(id), "Qualification track id is duplicated"); + var candidateId = identifier(text(item, "candidateId")); + var capability = text(item, "capability"); + require(CAPABILITIES.contains(capability), "Qualification capability is unsupported"); + var scope = text(item, "scope"); + require(SCOPES.contains(scope), "Qualification scope is unsupported"); + var requestPath = immutablePath(path, text(item, "requestPath")); + Path remediationPath = null; + if (!item.path("remediationRequestPath").isNull()) { + remediationPath = immutablePath(path, text(item, "remediationRequestPath")); + } + Path attestationPath = null; + if (!item.path("expectedAttestationPath").isNull()) { + attestationPath = boundedPath(path, text(item, "expectedAttestationPath")); + } + var protocolSha = text(item, "protocolSha256"); + require(protocolSha.matches("[a-f0-9]{64}"), "Qualification protocol checksum is invalid"); + var dependencies = new ArrayList(); + var dependencyNode = item.path("dependsOn"); + require(dependencyNode.isArray() && dependencyNode.size() <= 16, + "Qualification dependencies are invalid"); + dependencyNode.forEach(value -> dependencies.add(identifier(value.asText()))); + require(item.path("required").isBoolean(), "Qualification required flag is invalid"); + tracks.add(new Track(id, candidateId, capability, scope, requestPath, remediationPath, + attestationPath, protocolSha, List.copyOf(dependencies), item.path("required").booleanValue())); + } + for (var track : tracks) { + require(!track.dependsOn().contains(track.id()) && ids.containsAll(track.dependsOn()), + "Qualification dependency graph is invalid"); + } + rejectCycles(tracks); + return new QualificationCampaignManifest(campaignId, path, sha256(path), 1, List.copyOf(tracks)); + } + + private static void validateQuota(JsonNode quota) { + exact(quota, Set.of("sourceBytes", "derivedBytes", "modelBytes", "evidenceBytes", "reserveBytes")); + require(quota.path("sourceBytes").asLong(-1) == 45L * 1024 * 1024 * 1024, + "Qualification source quota is not frozen"); + require(quota.path("derivedBytes").asLong(-1) == 25L * 1024 * 1024 * 1024, + "Qualification derived quota is not frozen"); + require(quota.path("modelBytes").asLong(-1) == 10L * 1024 * 1024 * 1024, + "Qualification model quota is not frozen"); + require(quota.path("evidenceBytes").asLong(-1) == 10L * 1024 * 1024 * 1024, + "Qualification evidence quota is not frozen"); + require(quota.path("reserveBytes").asLong(-1) == 10L * 1024 * 1024 * 1024, + "Qualification reserve quota is not frozen"); + } + + private static void rejectCycles(List tracks) { + var byId = new java.util.HashMap(); + tracks.forEach(track -> byId.put(track.id(), track)); + for (var track : tracks) visit(track.id(), byId, new HashSet<>(), new HashSet<>()); + } + + private static void visit(String id, java.util.Map tracks, Set active, Set done) { + if (done.contains(id)) return; + require(active.add(id), "Qualification dependency graph contains a cycle"); + for (var dependency : tracks.get(id).dependsOn()) visit(dependency, tracks, active, done); + active.remove(id); + done.add(id); + } + + private static Path immutablePath(Path manifest, String value) { + var path = boundedPath(manifest, value); + require(Files.isRegularFile(path), "Qualification input file is unavailable"); + return path; + } + + private static Path boundedPath(Path manifest, String value) { + var base = manifest.getParent().toAbsolutePath().normalize(); + var candidate = Path.of(value); + var path = (candidate.isAbsolute() ? candidate : base.resolve(candidate)).toAbsolutePath().normalize(); + require(path.startsWith(base), "Qualification input escapes the campaign directory"); + return path; + } + + private static String sha256(Path path) throws IOException { + try (InputStream input = Files.newInputStream(path)) { + var digest = MessageDigest.getInstance("SHA-256"); + var buffer = new byte[64 * 1024]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + return HexFormat.of().formatHex(digest.digest()); + } catch (java.security.GeneralSecurityException impossible) { + throw new IOException("SHA-256 is unavailable", impossible); + } + } + + private static void exact(JsonNode value, Set expected) { + require(value != null && value.isObject(), "Qualification campaign object is invalid"); + var fields = new HashSet(); + value.fieldNames().forEachRemaining(fields::add); + require(fields.equals(expected), "Qualification campaign fields are invalid"); + } + + private static String text(JsonNode value, String field) { + var node = value.path(field); + require(node.isTextual() && !node.textValue().isBlank(), "Qualification field is invalid: " + field); + return node.textValue(); + } + + private static String identifier(String value) { + require(value != null && value.matches("[A-Za-z0-9._-]{1,120}"), "Qualification identifier is invalid"); + return value; + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + public record Track( + String id, String candidateId, String capability, String scope, + Path requestPath, Path remediationRequestPath, Path expectedAttestationPath, + String protocolSha256, List dependsOn, boolean required) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/QualificationReportValidator.java b/src/main/java/org/pathlab/forge/evidence/QualificationReportValidator.java new file mode 100644 index 0000000..9629b3c --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/QualificationReportValidator.java @@ -0,0 +1,103 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; + +/** Strict verifier for signed, non-publishable qualification attestations. */ +public final class QualificationReportValidator { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set VERDICTS = Set.of("qualified", "experimental", "unsupported", "not_evaluable"); + private final TrustedSignerRegistry registry; + + public QualificationReportValidator(Path stateRoot) { + registry = new TrustedSignerRegistry(stateRoot); + } + + public Validated validate(Path path, QualificationCampaignManifest campaign, + QualificationCampaignManifest.Track track) throws IOException { + var normalized = path.toAbsolutePath().normalize(); + require(Files.isRegularFile(normalized) && Files.size(normalized) <= 2L * 1024 * 1024, + "Qualification report is unavailable or too large"); + var root = JSON.readTree(normalized.toFile()); + exact(root, Set.of("schema", "campaignId", "campaignManifestSha256", "trackId", "candidateId", + "capability", "scope", "packManifestSha256", "protocolSha256", "generatedAt", + "researchOnly", "notDiagnostic", "status", "checks", "reasons", + "manifestSha256", "signature")); + require(QualificationReportWriter.SCHEMA.equals(root.path("schema").asText()), + "Qualification report schema is unsupported"); + require(campaign.campaignId().equals(root.path("campaignId").asText()) + && campaign.sha256().equals(root.path("campaignManifestSha256").asText()), + "Qualification report campaign identity changed"); + require(track.id().equals(root.path("trackId").asText()) + && track.candidateId().equals(root.path("candidateId").asText()) + && track.capability().equals(root.path("capability").asText()) + && track.scope().equals(root.path("scope").asText()), + "Qualification report track identity changed"); + require(track.protocolSha256().equals(root.path("protocolSha256").asText()), + "Qualification protocol checksum changed"); + require(root.path("packManifestSha256").asText().matches("[a-f0-9]{64}"), + "Qualification pack checksum is invalid"); + Instant.parse(root.path("generatedAt").asText()); + require(root.path("researchOnly").asBoolean(false) && root.path("notDiagnostic").asBoolean(false), + "Qualification report safety boundary is invalid"); + var verdict = root.path("status").asText(); + require(VERDICTS.contains(verdict), "Qualification verdict is invalid"); + require(root.path("checks").isArray() && !root.path("checks").isEmpty() + && root.path("checks").size() <= 200, "Qualification checks are invalid"); + require(root.path("reasons").isArray() && root.path("reasons").size() <= 100, + "Qualification reasons are invalid"); + var suppliedSha = root.path("manifestSha256").asText(); + require(suppliedSha.matches("[a-f0-9]{64}"), "Qualification report checksum is invalid"); + var unsigned = ((ObjectNode) root.deepCopy()); + unsigned.remove(Set.of("manifestSha256", "signature")); + var calculated = EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(unsigned)); + require(suppliedSha.equals(calculated), "Qualification report checksum mismatch"); + verify(root.path("signature"), suppliedSha); + return new Validated(verdict, suppliedSha, root.path("packManifestSha256").asText()); + } + + private void verify(JsonNode signature, String manifestSha) throws IOException { + exact(signature, Set.of("algorithm", "keyId", "value")); + require("Ed25519".equals(signature.path("algorithm").asText()), "Qualification signature is invalid"); + var keyId = signature.path("keyId").asText(); + require(keyId.matches("[a-f0-9]{64}"), "Qualification signer id is invalid"); + var publicBytes = registry.find(keyId, "qualification") + .orElseThrow(() -> new IllegalArgumentException("Qualification signer is not trusted")); + try { + var key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(publicBytes)); + var verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(key); + verifier.update((QualificationReportWriter.SCHEMA + "\n" + manifestSha) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var encoded = java.util.Base64.getUrlDecoder().decode(signature.path("value").asText()); + require(verifier.verify(encoded), "Qualification signature is invalid"); + } catch (IllegalArgumentException error) { + throw error; + } catch (Exception error) { + throw new IllegalArgumentException("Qualification signature is invalid", error); + } + } + + private static void exact(JsonNode value, Set expected) { + require(value != null && value.isObject(), "Qualification report object is invalid"); + var actual = new HashSet(); + value.fieldNames().forEachRemaining(actual::add); + require(actual.equals(expected), "Qualification report fields are invalid"); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + public record Validated(String verdict, String manifestSha256, String packManifestSha256) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/QualificationReportWriter.java b/src/main/java/org/pathlab/forge/evidence/QualificationReportWriter.java new file mode 100644 index 0000000..a83084d --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/QualificationReportWriter.java @@ -0,0 +1,54 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Map; + +/** Signs qualification reports while pinning trust outside the signed payload. */ +public final class QualificationReportWriter { + public static final String SCHEMA = "pathlab.model-qualification-report/2"; + private static final ObjectMapper JSON = new ObjectMapper() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private final EvidenceSigner signer; + private final TrustedSignerRegistry registry; + + public QualificationReportWriter(Path stateRoot) { + signer = new EvidenceSigner(stateRoot.resolve("signing")); + registry = new TrustedSignerRegistry(stateRoot); + } + + public EvidenceBundleWriter.Result write(Path output, ObjectNode unsigned) throws IOException { + require(SCHEMA.equals(unsigned.path("schema").asText()), "Qualification report schema is unsupported"); + require(unsigned.path("researchOnly").asBoolean(false), "Qualification report must be research-only"); + require(unsigned.path("notDiagnostic").asBoolean(false), "Qualification report must be non-diagnostic"); + require(!unsigned.has("manifestSha256") && !unsigned.has("signature"), + "Unsigned qualification report contains signature fields"); + var sha = EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(unsigned)); + var signed = signer.sign(SCHEMA + "\n" + sha); + registry.pin(signed.keyId(), signed.publicKeyDer(), "qualification"); + var complete = unsigned.deepCopy(); + complete.put("manifestSha256", sha); + complete.set("signature", JSON.valueToTree(Map.of( + "algorithm", "Ed25519", "keyId", signed.keyId(), "value", signed.signature()))); + var target = output.toAbsolutePath().normalize(); + Files.createDirectories(target.getParent()); + var partial = target.resolveSibling(target.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), complete); + try { + Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING); + } + return new EvidenceBundleWriter.Result(target, sha, signed.keyId()); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/QualificationRunSnapshot.java b/src/main/java/org/pathlab/forge/evidence/QualificationRunSnapshot.java new file mode 100644 index 0000000..fdd3a16 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/QualificationRunSnapshot.java @@ -0,0 +1,17 @@ +package org.pathlab.forge.evidence; + +import java.time.Instant; +import java.util.List; + +/** Operations-only campaign view; scientific measurements stay in signed reports. */ +public record QualificationRunSnapshot( + String id, String manifestSha256, String state, + boolean campaignCompleted, boolean campaignTargetMet, + boolean cancelRequested, List tracks, + Instant createdAt, Instant updatedAt) { + public record Track( + String id, String candidateId, String capability, String scope, + String state, String verdict, int attempt, String jobId, + String artifactSha256, String failureCode, String detail, + Instant updatedAt) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/QualificationRunStore.java b/src/main/java/org/pathlab/forge/evidence/QualificationRunStore.java new file mode 100644 index 0000000..3d9049b --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/QualificationRunStore.java @@ -0,0 +1,420 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +/** Durable parent coordinator. It schedules children but never consumes an execution lane. */ +public final class QualificationRunStore implements AutoCloseable { + private static final ObjectMapper JSON = new ObjectMapper(); + private final Connection connection; + private final Path stateRoot; + + public QualificationRunStore(Path database, Path stateRoot) throws IOException { + this.stateRoot = stateRoot.toAbsolutePath().normalize(); + try { + connection = DriverManager.getConnection("jdbc:sqlite:" + database.toAbsolutePath().normalize()); + try (var statement = connection.createStatement()) { + statement.execute("PRAGMA journal_mode=WAL"); + statement.execute("PRAGMA synchronous=FULL"); + statement.execute("PRAGMA busy_timeout=5000"); + statement.execute(""" + CREATE TABLE IF NOT EXISTS qualification_runs ( + id TEXT PRIMARY KEY, manifest_path TEXT NOT NULL, manifest_sha256 TEXT NOT NULL, + state TEXT NOT NULL, cancel_requested INTEGER NOT NULL, + campaign_completed INTEGER NOT NULL, campaign_target_met INTEGER NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + )"""); + statement.execute(""" + CREATE TABLE IF NOT EXISTS qualification_run_tracks ( + run_id TEXT NOT NULL, track_id TEXT NOT NULL, candidate_id TEXT NOT NULL, + capability TEXT NOT NULL, scope TEXT NOT NULL, request_path TEXT NOT NULL, + remediation_request_path TEXT NOT NULL, expected_attestation_path TEXT NOT NULL, + protocol_sha256 TEXT NOT NULL, dependencies_json TEXT NOT NULL, required INTEGER NOT NULL, + state TEXT NOT NULL, verdict TEXT NOT NULL, attempt INTEGER NOT NULL, job_id TEXT NOT NULL, + artifact_sha256 TEXT NOT NULL, failure_code TEXT NOT NULL, detail TEXT NOT NULL, + updated_at TEXT NOT NULL, PRIMARY KEY(run_id,track_id), + FOREIGN KEY(run_id) REFERENCES qualification_runs(id) + )"""); + statement.execute("CREATE INDEX IF NOT EXISTS ix_qualification_tracks_run_state ON qualification_run_tracks(run_id,state,track_id)"); + } + } catch (SQLException error) { + throw new IOException("Unable to open qualification campaign store", error); + } + } + + public synchronized QualificationRunSnapshot create(QualificationCampaignManifest manifest, Instant now) + throws IOException { + try { + begin(); + try (var statement = connection.prepareStatement(""" + INSERT INTO qualification_runs + (id,manifest_path,manifest_sha256,state,cancel_requested,campaign_completed, + campaign_target_met,created_at,updated_at) VALUES(?,?,?,?,0,0,0,?,?) + """)) { + statement.setString(1, manifest.campaignId()); + statement.setString(2, manifest.path().toString()); + statement.setString(3, manifest.sha256()); + statement.setString(4, "running"); + statement.setString(5, now.toString()); + statement.setString(6, now.toString()); + statement.executeUpdate(); + } + for (var track : manifest.tracks()) insertTrack(manifest.campaignId(), track, now); + commit(); + return snapshot(manifest.campaignId()).orElseThrow(); + } catch (SQLException error) { + rollbackQuietly(); + if (error.getErrorCode() == 19) throw new IllegalArgumentException("Qualification campaign already exists", error); + throw new IOException("Unable to create qualification campaign", error); + } + } + + public synchronized Optional snapshot(String id) throws IOException { + try (var statement = connection.prepareStatement("SELECT * FROM qualification_runs WHERE id=?")) { + statement.setString(1, id); + try (var rows = statement.executeQuery()) { + if (!rows.next()) return Optional.empty(); + return Optional.of(readRun(rows, tracks(id))); + } + } catch (SQLException error) { + throw new IOException("Unable to read qualification campaign", error); + } + } + + public synchronized List list(int limit) throws IOException { + if (limit < 1 || limit > 100) throw new IllegalArgumentException("Qualification campaign limit is invalid"); + var result = new ArrayList(); + try (var statement = connection.prepareStatement( + "SELECT * FROM qualification_runs ORDER BY updated_at DESC,id LIMIT ?")) { + statement.setInt(1, limit); + try (var rows = statement.executeQuery()) { + while (rows.next()) result.add(readRun(rows, tracks(rows.getString("id")))); + } + } catch (SQLException error) { + throw new IOException("Unable to list qualification campaigns", error); + } + return List.copyOf(result); + } + + public synchronized void requestCancel(String id, EvidenceJobQueue queue, Instant now) throws IOException { + var run = snapshot(id).orElseThrow(() -> new IllegalArgumentException("Qualification campaign was not found")); + if (run.campaignCompleted()) return; + execute("UPDATE qualification_runs SET cancel_requested=1,state='cancelling',updated_at=? WHERE id=?", + now.toString(), id); + for (var track : run.tracks()) { + if (!track.jobId().isBlank()) { + var job = queue.snapshot(track.jobId()); + if (job.isPresent() && !job.get().state().terminal()) queue.requestCancel(track.jobId(), now); + } + if (track.verdict().isBlank()) terminalTrack(id, track.id(), "not_evaluable", + "CAMPAIGN_CANCELLED", "Cancelled by the local operator", now); + } + finalizeRun(id, now); + } + + public synchronized QualificationRunSnapshot resume(String id, Instant now) throws IOException { + var run = snapshot(id).orElseThrow(() -> new IllegalArgumentException("Qualification campaign was not found")); + if (run.campaignCompleted()) throw new IllegalStateException("Qualification campaign is already terminal"); + execute("UPDATE qualification_runs SET state='running',cancel_requested=0,updated_at=? WHERE id=?", + now.toString(), id); + execute("UPDATE qualification_run_tracks SET state='pending',updated_at=? WHERE run_id=? AND state='waiting_attestation'", + now.toString(), id); + return snapshot(id).orElseThrow(); + } + + public synchronized QualificationRunSnapshot attachAttestation(String id, String trackId, Path report, + Instant now) throws IOException { + var manifest = loadManifest(id); + var track = manifest.tracks().stream().filter(item -> item.id().equals(trackId)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Qualification track was not found")); + var validated = new QualificationReportValidator(stateRoot).validate(report, manifest, track); + if ("qualified".equals(validated.verdict()) && !"local-benchmark".equals(track.scope())) { + var request = JSON.readTree(track.requestPath().toFile()); + var packPath = Path.of(request.path("packManifest").asText()).toAbsolutePath().normalize(); + var pack = EvidencePackManifest.load(packPath); + if (!pack.sha256().equals(validated.packManifestSha256())) { + throw new IllegalArgumentException("Qualification report pack checksum changed"); + } + new QualifiedPackRegistry(stateRoot).register(pack, track.protocolSha256(), + validated.manifestSha256(), now); + } + terminalTrack(id, trackId, validated.verdict(), "", + "Signed qualification attestation accepted", now, validated.manifestSha256()); + finalizeRun(id, now); + return snapshot(id).orElseThrow(); + } + + /** Advances every parent deterministically; active scientific work remains in child jobs. */ + public synchronized void tick(EvidenceJobQueue queue, Instant now) throws IOException { + for (var run : list(100)) { + if (run.campaignCompleted()) continue; + QualificationCampaignManifest manifest; + try { + manifest = loadManifest(run.id()); + } catch (IllegalArgumentException error) { + failRemaining(run.id(), "CAMPAIGN_MANIFEST_CHANGED", error.getMessage(), now); + finalizeRun(run.id(), now); + continue; + } + for (var track : snapshot(run.id()).orElseThrow().tracks()) { + if (!track.verdict().isBlank()) continue; + var declared = manifest.tracks().stream().filter(item -> item.id().equals(track.id())).findFirst().orElseThrow(); + if ("pending".equals(track.state())) { + var dependencyVerdicts = dependencyVerdicts(run.id(), declared.dependsOn()); + if (dependencyVerdicts.stream().anyMatch(value -> !value.isBlank() && !"qualified".equals(value))) { + terminalTrack(run.id(), track.id(), "not_evaluable", "DEPENDENCY_NOT_QUALIFIED", + "A frozen dependency did not qualify", now); + } else if (dependencyVerdicts.stream().allMatch("qualified"::equals)) { + try { + submitChild(run.id(), declared, track.attempt(), queue, now); + } catch (IllegalArgumentException invalidCandidate) { + terminalTrack(run.id(), track.id(), "not_evaluable", "CANDIDATE_PREFLIGHT_FAILED", + bounded(invalidCandidate.getMessage()), now); + } + } + continue; + } + if ("waiting_attestation".equals(track.state())) { + if (declared.expectedAttestationPath() != null && Files.isRegularFile(declared.expectedAttestationPath())) { + try { + attachAttestation(run.id(), track.id(), declared.expectedAttestationPath(), now); + } catch (IllegalArgumentException invalid) { + remediateOrFinish(run.id(), declared, track, "ATTESTATION_INVALID", invalid.getMessage(), now); + } + } + continue; + } + if (!track.jobId().isBlank()) { + var job = queue.snapshot(track.jobId()); + if (job.isPresent() && job.get().state().terminal()) { + handleTerminalJob(run.id(), declared, track, job.get(), now); + } + } + } + finalizeRun(run.id(), now); + } + } + + private void handleTerminalJob(String runId, QualificationCampaignManifest.Track declared, + QualificationRunSnapshot.Track track, EvidenceJobSnapshot job, Instant now) throws IOException { + switch (job.state()) { + case COMPLETED -> { + if (declared.expectedAttestationPath() != null && Files.isRegularFile(declared.expectedAttestationPath())) { + try { + attachAttestation(runId, track.id(), declared.expectedAttestationPath(), now); + } catch (IllegalArgumentException invalid) { + remediateOrFinish(runId, declared, track, "ATTESTATION_INVALID", invalid.getMessage(), now); + } + } else { + updateTrack(runId, track.id(), "waiting_attestation", track.attempt(), track.jobId(), "", + "", "Child completed; awaiting a signed qualification attestation", now); + execute("UPDATE qualification_runs SET state='waiting_attestation',updated_at=? WHERE id=?", + now.toString(), runId); + } + } + case UNSUPPORTED -> terminalTrack(runId, track.id(), "unsupported", + blankCode(job.failureCode(), "CAPABILITY_UNSUPPORTED"), job.detail(), now); + case ABSTAINED -> terminalTrack(runId, track.id(), "not_evaluable", + blankCode(job.failureCode(), "CHILD_ABSTAINED"), job.detail(), now); + case FAILED, CANCELLED -> remediateOrFinish(runId, declared, track, + blankCode(job.failureCode(), "CHILD_FAILED"), job.detail(), now); + default -> { } + } + } + + private void remediateOrFinish(String runId, QualificationCampaignManifest.Track declared, + QualificationRunSnapshot.Track current, String code, String detail, Instant now) throws IOException { + if (current.attempt() == 0 && declared.remediationRequestPath() != null) { + execute(""" + UPDATE qualification_run_tracks SET request_path=?,state='pending',attempt=1,job_id='', + failure_code=?,detail=?,updated_at=? WHERE run_id=? AND track_id=? + """, declared.remediationRequestPath().toString(), code, + "Bounded remediation scheduled: " + bounded(detail), now.toString(), runId, current.id()); + execute("UPDATE qualification_runs SET state='remediating',updated_at=? WHERE id=?", + now.toString(), runId); + } else { + terminalTrack(runId, current.id(), "experimental", code, bounded(detail), now); + } + } + + private void submitChild(String runId, QualificationCampaignManifest.Track track, int attempt, + EvidenceJobQueue queue, Instant now) throws IOException { + var request = attempt == 0 ? track.requestPath() : track.remediationRequestPath(); + var jobId = childId(runId, track.id(), attempt); + var plan = EvidenceJobProcessor.executionPlan(request, jobId, stateRoot); + queue.submit(jobId, request, plan.lane(), plan.packSha256(), now); + updateTrack(runId, track.id(), "running", attempt, jobId, "", "", + "Child job accepted by the autonomous runner", now); + } + + private QualificationCampaignManifest loadManifest(String id) throws IOException { + try (var statement = connection.prepareStatement( + "SELECT manifest_path,manifest_sha256 FROM qualification_runs WHERE id=?")) { + statement.setString(1, id); + try (var rows = statement.executeQuery()) { + if (!rows.next()) throw new IllegalArgumentException("Qualification campaign was not found"); + var manifest = QualificationCampaignManifest.load(Path.of(rows.getString(1))); + if (!manifest.sha256().equals(rows.getString(2))) { + throw new IllegalArgumentException("Qualification campaign checksum changed"); + } + return manifest; + } + } catch (SQLException error) { + throw new IOException("Unable to load qualification campaign manifest", error); + } + } + + private List dependencyVerdicts(String runId, List dependencies) throws IOException { + var result = new ArrayList(); + for (var dependency : dependencies) { + try (var statement = connection.prepareStatement( + "SELECT verdict FROM qualification_run_tracks WHERE run_id=? AND track_id=?")) { + statement.setString(1, runId); + statement.setString(2, dependency); + try (var rows = statement.executeQuery()) { + result.add(rows.next() ? rows.getString(1) : "not_evaluable"); + } + } catch (SQLException error) { + throw new IOException("Unable to read qualification dependency", error); + } + } + return result; + } + + private void finalizeRun(String id, Instant now) throws IOException { + var tracks = tracks(id); + if (tracks.isEmpty() || tracks.stream().anyMatch(track -> track.verdict().isBlank())) return; + var target = tracks.stream().allMatch(track -> "qualified".equals(track.verdict())); + execute(""" + UPDATE qualification_runs SET state='completed',campaign_completed=1, + campaign_target_met=?,updated_at=? WHERE id=? + """, target ? "1" : "0", now.toString(), id); + new CapabilityMatrixWriter(stateRoot).write(snapshot(id).orElseThrow(), now); + } + + private void failRemaining(String id, String code, String detail, Instant now) throws IOException { + execute(""" + UPDATE qualification_run_tracks SET state='terminal',verdict='not_evaluable', + failure_code=?,detail=?,updated_at=? WHERE run_id=? AND verdict='' + """, code, bounded(detail), now.toString(), id); + } + + private void terminalTrack(String runId, String trackId, String verdict, String failureCode, + String detail, Instant now) throws IOException { + terminalTrack(runId, trackId, verdict, failureCode, detail, now, ""); + } + + private void terminalTrack(String runId, String trackId, String verdict, String failureCode, + String detail, Instant now, String artifactSha) throws IOException { + if (!java.util.Set.of("qualified", "experimental", "unsupported", "not_evaluable").contains(verdict)) { + throw new IllegalArgumentException("Qualification terminal verdict is invalid"); + } + execute(""" + UPDATE qualification_run_tracks SET state='terminal',verdict=?,artifact_sha256=?, + failure_code=?,detail=?,updated_at=? WHERE run_id=? AND track_id=? + """, verdict, artifactSha, failureCode, bounded(detail), now.toString(), runId, trackId); + } + + private void updateTrack(String runId, String trackId, String state, int attempt, String jobId, + String artifactSha, String failureCode, String detail, Instant now) throws IOException { + execute(""" + UPDATE qualification_run_tracks SET state=?,attempt=?,job_id=?,artifact_sha256=?, + failure_code=?,detail=?,updated_at=? WHERE run_id=? AND track_id=? + """, state, Integer.toString(attempt), jobId, artifactSha, failureCode, + bounded(detail), now.toString(), runId, trackId); + } + + private void insertTrack(String runId, QualificationCampaignManifest.Track track, Instant now) + throws SQLException, IOException { + try (var statement = connection.prepareStatement(""" + INSERT INTO qualification_run_tracks + (run_id,track_id,candidate_id,capability,scope,request_path,remediation_request_path, + expected_attestation_path,protocol_sha256,dependencies_json,required,state,verdict, + attempt,job_id,artifact_sha256,failure_code,detail,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,'pending','',0,'','','','Waiting for frozen dependencies',?) + """)) { + statement.setString(1, runId); + statement.setString(2, track.id()); + statement.setString(3, track.candidateId()); + statement.setString(4, track.capability()); + statement.setString(5, track.scope()); + statement.setString(6, track.requestPath().toString()); + statement.setString(7, track.remediationRequestPath() == null ? "" : track.remediationRequestPath().toString()); + statement.setString(8, track.expectedAttestationPath() == null ? "" : track.expectedAttestationPath().toString()); + statement.setString(9, track.protocolSha256()); + statement.setString(10, JSON.writeValueAsString(track.dependsOn())); + statement.setInt(11, track.required() ? 1 : 0); + statement.setString(12, now.toString()); + statement.executeUpdate(); + } + } + + private List tracks(String id) throws IOException { + var result = new ArrayList(); + try (var statement = connection.prepareStatement( + "SELECT * FROM qualification_run_tracks WHERE run_id=? ORDER BY track_id")) { + statement.setString(1, id); + try (var rows = statement.executeQuery()) { + while (rows.next()) result.add(new QualificationRunSnapshot.Track( + rows.getString("track_id"), rows.getString("candidate_id"), + rows.getString("capability"), rows.getString("scope"), rows.getString("state"), + rows.getString("verdict"), rows.getInt("attempt"), rows.getString("job_id"), + rows.getString("artifact_sha256"), rows.getString("failure_code"), + rows.getString("detail"), Instant.parse(rows.getString("updated_at")))); + } + } catch (SQLException error) { + throw new IOException("Unable to read qualification campaign tracks", error); + } + return List.copyOf(result); + } + + private static QualificationRunSnapshot readRun(ResultSet rows, + List tracks) throws SQLException { + return new QualificationRunSnapshot(rows.getString("id"), rows.getString("manifest_sha256"), + rows.getString("state"), rows.getInt("campaign_completed") != 0, + rows.getInt("campaign_target_met") != 0, rows.getInt("cancel_requested") != 0, + tracks, Instant.parse(rows.getString("created_at")), Instant.parse(rows.getString("updated_at"))); + } + + private static String childId(String runId, String trackId, int attempt) { + try { + var bytes = MessageDigest.getInstance("SHA-256") + .digest((runId + "\n" + trackId + "\n" + attempt).getBytes(StandardCharsets.UTF_8)); + return "qualification-" + HexFormat.of().formatHex(bytes).substring(0, 32); + } catch (java.security.GeneralSecurityException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private void execute(String sql, String... values) throws IOException { + try (var statement = connection.prepareStatement(sql)) { + for (var index = 0; index < values.length; index++) statement.setString(index + 1, values[index]); + statement.executeUpdate(); + } catch (SQLException error) { + throw new IOException("Unable to update qualification campaign", error); + } + } + + private void begin() throws SQLException { try (var statement = connection.createStatement()) { statement.execute("BEGIN IMMEDIATE"); } } + private void commit() throws SQLException { try (var statement = connection.createStatement()) { statement.execute("COMMIT"); } } + private void rollbackQuietly() { try (var statement = connection.createStatement()) { statement.execute("ROLLBACK"); } catch (SQLException ignored) { } } + private static String blankCode(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; } + private static String bounded(String value) { if (value == null || value.isBlank()) return "No additional detail"; return value.length() <= 500 ? value : value.substring(0, 500); } + + @Override public synchronized void close() throws IOException { + try { connection.close(); } catch (SQLException error) { throw new IOException("Unable to close qualification campaign store", error); } + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/QualifiedPackRegistry.java b/src/main/java/org/pathlab/forge/evidence/QualifiedPackRegistry.java new file mode 100644 index 0000000..4b15047 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/QualifiedPackRegistry.java @@ -0,0 +1,99 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.Set; + +/** Local fail-closed activation registry, separate from mutable candidate manifests. */ +public final class QualifiedPackRegistry { + private static final ObjectMapper JSON = new ObjectMapper() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private final Path path; + + public QualifiedPackRegistry(Path stateRoot) { + path = stateRoot.toAbsolutePath().normalize().resolve("trust/qualified-packs.json"); + } + + public synchronized void register(EvidencePackManifest pack, String protocolSha256, + String attestationSha256, Instant acceptedAt) throws IOException { + require(EvidencePackManifest.SCHEMA_V2.equals(pack.schema()), "Only v2 packs use qualification registry"); + require(pack.activationEligible() && !"local-benchmark".equals(pack.scope()), + "Pack scope is not activation eligible"); + require(protocolSha256.matches("[a-f0-9]{64}") && attestationSha256.matches("[a-f0-9]{64}"), + "Qualification registry hashes are invalid"); + var root = load(); + var entries = (ArrayNode) root.path("entries"); + for (var item : entries) { + if (pack.sha256().equals(item.path("packManifestSha256").asText())) { + require(attestationSha256.equals(item.path("attestationSha256").asText()) + && protocolSha256.equals(item.path("protocolSha256").asText()), + "Pack checksum is already registered with different qualification evidence"); + return; + } + } + var entry = entries.addObject(); + entry.put("packId", pack.packId()); + entry.put("packVersion", pack.version()); + entry.put("packManifestSha256", pack.sha256()); + entry.put("protocolSha256", protocolSha256); + entry.put("attestationSha256", attestationSha256); + entry.put("scope", pack.scope()); + entry.put("acceptedAt", acceptedAt.toString()); + entry.set("artifactSha256s", JSON.valueToTree(pack.artifacts().stream() + .map(EvidencePackManifest.Artifact::sha256).toList())); + write(root); + } + + public synchronized void requireQualified(EvidencePackManifest pack, String attestationSha256) + throws IOException { + require(attestationSha256 != null && attestationSha256.matches("[a-f0-9]{64}"), + "Qualified pack attestation checksum is required"); + for (var item : load().path("entries")) { + if (pack.packId().equals(item.path("packId").asText()) + && pack.version().equals(item.path("packVersion").asText()) + && pack.sha256().equals(item.path("packManifestSha256").asText()) + && attestationSha256.equals(item.path("attestationSha256").asText()) + && pack.scope().equals(item.path("scope").asText()) + && item.path("artifactSha256s").equals(JSON.valueToTree(pack.artifacts().stream() + .map(EvidencePackManifest.Artifact::sha256).toList()))) return; + } + throw new IllegalArgumentException("AI pack has no matching qualified-registry entry"); + } + + private ObjectNode load() throws IOException { + if (!Files.isRegularFile(path)) { + var root = JSON.createObjectNode(); + root.put("schema", "pathlab.qualified-pack-registry/1"); + root.set("entries", JSON.createArrayNode()); + return root; + } + var root = JSON.readTree(path.toFile()); + require(root.isObject() && root.size() == 2 + && "pathlab.qualified-pack-registry/1".equals(root.path("schema").asText()) + && root.path("entries").isArray(), "Qualified pack registry is invalid"); + return (ObjectNode) root; + } + + private void write(ObjectNode root) throws IOException { + Files.createDirectories(path.getParent()); + var partial = path.resolveSibling(path.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), root); + try { + Files.move(partial, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(partial, path, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } +} diff --git a/src/main/java/org/pathlab/forge/evidence/SpecialStainAnalyzer.java b/src/main/java/org/pathlab/forge/evidence/SpecialStainAnalyzer.java new file mode 100644 index 0000000..3a7638f --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/SpecialStainAnalyzer.java @@ -0,0 +1,67 @@ +package org.pathlab.forge.evidence; + +import java.awt.image.BufferedImage; +import java.util.Map; +import java.util.Set; + +/** Descriptive color/OD measurements for supported brightfield special stains and cytology. */ +public final class SpecialStainAnalyzer { + private static final Set SUPPORTED = Set.of( + "pas", "pas_d", "trichrome", "gms", "afb", "papanicolaou", "generic_brightfield"); + private SpecialStainAnalyzer() { } + + public static Result analyze(BufferedImage image, String requestedStain) { + if (image == null || image.getWidth() < 1 || image.getHeight() < 1) { + throw new IllegalArgumentException("Special-stain image geometry is invalid"); + } + var stain = requestedStain == null ? "generic_brightfield" : requestedStain.toLowerCase(); + var fallback = !SUPPORTED.contains(stain); + if (fallback) stain = "generic_brightfield"; + var stained = 0; + var tissue = 0; + var saturated = 0; + double od = 0; + for (var y = 0; y < image.getHeight(); y++) for (var x = 0; x < image.getWidth(); x++) { + var rgb = image.getRGB(x, y); + var red = rgb >>> 16 & 0xff; + var green = rgb >>> 8 & 0xff; + var blue = rgb & 0xff; + if ((red + green + blue) / 3.0 < 235) tissue++; + if (red < 3 || green < 3 || blue < 3) saturated++; + if (matches(stain, red, green, blue)) { + stained++; + var luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + od += -Math.log((luminance + 1) / 256.0); + } + } + var reasons = new java.util.ArrayList(); + if (tissue < image.getWidth() * image.getHeight() * 0.05) reasons.add("insufficient_tissue"); + if (saturated > image.getWidth() * image.getHeight() * 0.35) reasons.add("excessive_saturation"); + var measurement = Map.of( + "stainAreaFraction", tissue == 0 ? 0 : (double) stained / tissue, + "meanStainOd", stained == 0 ? 0 : od / stained, + "tissuePixelCount", tissue, + "stainedPixelCount", stained); + return new Result(stain, fallback ? "generic-descriptive" : mode(stain), measurement, + fallback ? "UNSUPPORTED_STAIN_GENERIC_FALLBACK" : reasons.isEmpty() ? null : "STAIN_QC_FAILED", + java.util.List.copyOf(reasons), reasons.isEmpty() ? 0.15 : 1.0); + } + + private static boolean matches(String stain, int red, int green, int blue) { + return switch (stain) { + case "pas", "pas_d" -> red - green >= 30 && blue - green >= 15; + case "trichrome" -> blue - red >= 20 || green - red >= 20; + case "gms" -> red < 80 && green < 80 && blue < 80; + case "afb" -> red - green >= 50 && red - blue >= 40; + case "papanicolaou" -> Math.max(red, Math.max(green, blue)) - Math.min(red, Math.min(green, blue)) >= 35; + default -> red + green + blue < 600; + }; + } + + private static String mode(String stain) { + return "papanicolaou".equals(stain) ? "cytology-descriptive" : "special-stain-descriptive"; + } + + public record Result(String stainId, String analysisMode, Map measurements, + String abstentionReason, java.util.List qc, double uncertainty) { } +} diff --git a/src/main/java/org/pathlab/forge/evidence/TrustedSignerRegistry.java b/src/main/java/org/pathlab/forge/evidence/TrustedSignerRegistry.java new file mode 100644 index 0000000..71e6cf6 --- /dev/null +++ b/src/main/java/org/pathlab/forge/evidence/TrustedSignerRegistry.java @@ -0,0 +1,103 @@ +package org.pathlab.forge.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Optional; + +/** ACL-protected signer registry; payload-embedded public keys are never trust anchors. */ +public final class TrustedSignerRegistry { + private static final ObjectMapper JSON = new ObjectMapper() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private final Path path; + + public TrustedSignerRegistry(Path stateRoot) { + path = stateRoot.toAbsolutePath().normalize().resolve("trust/approved-signers.json"); + } + + public synchronized void pin(String keyId, String publicKeyDer, String allowedUse) throws IOException { + require(keyId != null && keyId.matches("[a-f0-9]{64}"), "Trusted signer id is invalid"); + require(SetHolder.USES.contains(allowedUse), "Trusted signer use is invalid"); + byte[] publicBytes; + try { + publicBytes = Base64.getUrlDecoder().decode(publicKeyDer); + } catch (IllegalArgumentException error) { + throw new IllegalArgumentException("Trusted signer key is invalid", error); + } + require(EvidenceBundleWriter.sha256(publicBytes).equals(keyId), + "Trusted signer key does not match its id"); + var root = load(); + var keys = (ArrayNode) root.path("keys"); + for (var item : keys) { + if (keyId.equals(item.path("keyId").asText()) && allowedUse.equals(item.path("allowedUse").asText())) { + require(publicKeyDer.equals(item.path("publicKeyDer").asText()), + "Trusted signer id is already pinned to a different key"); + return; + } + } + var item = keys.addObject(); + item.put("keyId", keyId); + item.put("publicKeyDer", publicKeyDer); + item.put("allowedUse", allowedUse); + write(root); + } + + public synchronized Optional find(String keyId, String allowedUse) throws IOException { + var root = load(); + for (var item : root.path("keys")) { + if (keyId.equals(item.path("keyId").asText()) && allowedUse.equals(item.path("allowedUse").asText())) { + try { + var value = Base64.getUrlDecoder().decode(item.path("publicKeyDer").asText()); + if (MessageDigest.isEqual( + EvidenceBundleWriter.sha256(value).getBytes(java.nio.charset.StandardCharsets.US_ASCII), + keyId.getBytes(java.nio.charset.StandardCharsets.US_ASCII))) return Optional.of(value); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + } + return Optional.empty(); + } + + private ObjectNode load() throws IOException { + if (!Files.isRegularFile(path)) { + var root = JSON.createObjectNode(); + root.put("schema", "pathlab.trusted-signers/1"); + root.set("keys", JSON.createArrayNode()); + return root; + } + var root = JSON.readTree(path.toFile()); + require(root.isObject() && "pathlab.trusted-signers/1".equals(root.path("schema").asText()) + && root.path("keys").isArray(), "Trusted signer registry is invalid"); + var copy = (ObjectNode) root; + require(copy.size() == 2, "Trusted signer registry fields are invalid"); + return copy; + } + + private void write(ObjectNode value) throws IOException { + Files.createDirectories(path.getParent()); + var partial = path.resolveSibling(path.getFileName() + ".partial"); + JSON.writerWithDefaultPrettyPrinter().writeValue(partial.toFile(), value); + try { + Files.move(partial, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(partial, path, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + + private static final class SetHolder { + private static final java.util.Set USES = java.util.Set.of("qualification", "evidence-set", "evidence"); + } +} diff --git a/src/main/java/org/pathlab/forge/server/ForgeServer.java b/src/main/java/org/pathlab/forge/server/ForgeServer.java index cbc7a95..5a04b21 100644 --- a/src/main/java/org/pathlab/forge/server/ForgeServer.java +++ b/src/main/java/org/pathlab/forge/server/ForgeServer.java @@ -1,5 +1,6 @@ package org.pathlab.forge.server; +import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; @@ -28,6 +29,7 @@ import org.pathlab.forge.conversion.SeriesInfo; import org.pathlab.forge.derivative.DerivativeEngine; import org.pathlab.forge.derivative.VipsRuntime; +import org.pathlab.forge.evidence.EvidenceMentorRunner; import org.pathlab.forge.feature.CapabilityRegistry; import org.pathlab.forge.feature.FeaturePackDescriptor; import org.pathlab.forge.feature.FeaturePackManager; @@ -53,6 +55,7 @@ import org.pathlab.forge.viewer.WindowsCredentialStore; public final class ForgeServer implements AutoCloseable { + private static final ObjectMapper JSON = new ObjectMapper(); private static final int MAX_WRITE_BYTES = 65_536; private static final int DEFAULT_DESKTOP_PORT = 51_274; private static final long SESSION_MAX_AGE_SECONDS = 315_360_000L; @@ -302,6 +305,10 @@ private void handle(HttpExchange exchange) throws IOException { session(exchange); } else if ("/api/capabilities".equals(path) && "GET".equals(exchange.getRequestMethod())) { capabilities(exchange); + } else if ("/api/evidence/status".equals(path) && "GET".equals(exchange.getRequestMethod())) { + evidenceRunnerStatus(exchange); + } else if ("/api/evidence/dashboard".equals(path) && "POST".equals(exchange.getRequestMethod())) { + openEvidenceDashboard(exchange); } else if ("/api/features".equals(path) && "GET".equals(exchange.getRequestMethod())) { features(exchange); } else if (path.matches("/api/features/[a-z0-9][a-z0-9-]{1,63}/install") @@ -757,6 +764,74 @@ private void revokeViewerConnection(HttpExchange exchange) throws IOException { } } + private void evidenceRunnerStatus(HttpExchange exchange) throws IOException { + if (!requireAuthenticated(exchange)) return; + var stateRoot = EvidenceMentorRunner.defaultStateRoot(); + var tokenPath = stateRoot.resolve("ipc-token"); + var endpointPath = stateRoot.resolve("endpoint.json"); + if (!Files.isRegularFile(tokenPath) || !Files.isRegularFile(endpointPath)) { + respond(exchange, 503, "application/json", + "{\"schema\":\"pathlab.evidence-runner-status/2\",\"status\":\"unavailable\"," + + "\"detail\":\"Evidence Mentor runner is not installed\"}"); + return; + } + try { + var endpoint = JSON.readTree(endpointPath.toFile()); + if (!"pathlab.runner-endpoint/1".equals(endpoint.path("schema").asText()) + || !endpoint.path("port").canConvertToInt() + || endpoint.path("port").asInt() < 1 || endpoint.path("port").asInt() > 65535) { + throw new IOException("Evidence Mentor endpoint is invalid"); + } + var port = endpoint.path("port").asInt(); + var request = java.net.http.HttpRequest.newBuilder( + URI.create("http://127.0.0.1:" + port + "/v1/status")) + .timeout(java.time.Duration.ofSeconds(2)) + .header("Authorization", "Bearer " + Files.readString(tokenPath).trim()) + .GET().build(); + var response = java.net.http.HttpClient.newHttpClient().send( + request, java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200 || response.body().length() > 65_536) { + throw new IOException("Evidence Mentor status is unavailable"); + } + respond(exchange, 200, "application/json", response.body()); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + respond(exchange, 503, "application/json", "{\"schema\":\"pathlab.evidence-runner-status/2\"," + + "\"status\":\"unavailable\",\"detail\":\"Status request was interrupted\"}"); + } catch (IOException | IllegalArgumentException error) { + respond(exchange, 503, "application/json", "{\"schema\":\"pathlab.evidence-runner-status/2\"," + + "\"status\":\"unavailable\",\"detail\":\"Runner is not reachable\"}"); + } + } + + private void openEvidenceDashboard(HttpExchange exchange) throws IOException { + if (!requireWrite(exchange)) return; + var stateRoot = EvidenceMentorRunner.defaultStateRoot(); + try { + var endpoint = JSON.readTree(stateRoot.resolve("endpoint.json").toFile()); + var port = endpoint.path("port").asInt(); + if (!"pathlab.runner-endpoint/1".equals(endpoint.path("schema").asText()) + || port < 1 || port > 65535) throw new IOException("Runner endpoint is invalid"); + var origin = "http://127.0.0.1:" + port; + var request = java.net.http.HttpRequest.newBuilder(URI.create(origin + "/v1/dashboard-sessions")) + .timeout(java.time.Duration.ofSeconds(2)) + .header("Authorization", "Bearer " + Files.readString(stateRoot.resolve("ipc-token")).trim()) + .POST(java.net.http.HttpRequest.BodyPublishers.noBody()).build(); + var response = java.net.http.HttpClient.newHttpClient().send(request, + java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201 || response.body().length() > 4_096) throw new IOException("Dashboard session was refused"); + var result = JSON.readTree(response.body()); + var code = result.path("code").asText(); + if (!code.matches("[A-Za-z0-9_-]{40,80}")) throw new IOException("Dashboard session code is invalid"); + respond(exchange, 200, "application/json", "{\"url\":" + json(origin + "/dashboard/#" + code) + "}"); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + respond(exchange, 503, "application/json", "{\"error\":\"dashboard_unavailable\",\"detail\":\"Dashboard request was interrupted\"}"); + } catch (IOException | IllegalArgumentException error) { + respond(exchange, 503, "application/json", "{\"error\":\"dashboard_unavailable\",\"detail\":\"Evidence Mentor dashboard is not reachable\"}"); + } + } + private void viewerUploadStatus(HttpExchange exchange) throws IOException { if (!requireAuthenticated(exchange)) { return; diff --git a/src/main/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilder.java b/src/main/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilder.java index 4fb8af0..5fbc388 100644 --- a/src/main/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilder.java +++ b/src/main/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilder.java @@ -29,6 +29,15 @@ public Result build( String artifactRevisionId, String slideSha256, List annotations) throws IOException { + return build(output, artifactRevisionId, slideSha256, annotations, null); + } + + public Result build( + Path output, + String artifactRevisionId, + String slideSha256, + List annotations, + Path evidenceManifest) throws IOException { var manifest = JSON.writeValueAsBytes(Map.of( "schema", "pathlab-private-results/v1", "artifactRevisionId", artifactRevisionId, @@ -68,6 +77,13 @@ public Result build( entries.put("objects.ndjson", objects.toString().getBytes(StandardCharsets.UTF_8)); entries.put("measurements.ndjson", measurements.toString().getBytes(StandardCharsets.UTF_8)); entries.put("runs.ndjson", runs.getBytes(StandardCharsets.UTF_8)); + if (evidenceManifest != null) { + var evidence = Files.readAllBytes(evidenceManifest.toAbsolutePath().normalize()); + if (evidence.length == 0 || evidence.length > 2 * 1024 * 1024) { + throw new IOException("Evidence manifest exceeds delivery limit"); + } + entries.put("evidence.json", evidence); + } var normalized = output.toAbsolutePath().normalize(); Files.createDirectories(normalized.getParent()); var partial = normalized.resolveSibling(normalized.getFileName() + ".partial"); diff --git a/src/main/java/org/pathlab/forge/viewer/ViewerPairingService.java b/src/main/java/org/pathlab/forge/viewer/ViewerPairingService.java index abedea6..31b8959 100644 --- a/src/main/java/org/pathlab/forge/viewer/ViewerPairingService.java +++ b/src/main/java/org/pathlab/forge/viewer/ViewerPairingService.java @@ -22,6 +22,8 @@ import java.util.concurrent.Executors; import java.util.regex.Pattern; import org.pathlab.forge.annotation.AnnotationRecord; +import org.pathlab.forge.evidence.EvidenceArtifactStore; +import org.pathlab.forge.evidence.EvidenceMentorRunner; import org.pathlab.forge.annotation.AnnotationTransformer; import org.pathlab.forge.conversion.ArtifactIntegrityStamp; import org.pathlab.forge.conversion.ArtifactRevision; @@ -646,8 +648,11 @@ private void syncPrivateResults( .toList(); var sidecar = Path.of(revision.omePath()).resolveSibling( revision.id() + ".plresults"); + var evidence = EvidenceArtifactStore.find( + EvidenceMentorRunner.defaultStateRoot(), revision.omeSha256(), revision.id()) + .orElse(null); var bundle = new PrivateResultsBundleBuilder().build( - sidecar, revision.id(), revision.omeSha256(), transformed); + sidecar, revision.id(), revision.omeSha256(), transformed, evidence); var create = sendJson( credential.base().resolve("/api/v2/desktop/slides/" + readyJob.remoteSlideId() + "/result-deliveries"), diff --git a/src/main/resources/evidence-dashboard/index.html b/src/main/resources/evidence-dashboard/index.html new file mode 100644 index 0000000..51ff95c --- /dev/null +++ b/src/main/resources/evidence-dashboard/index.html @@ -0,0 +1,33 @@ + + + + + + PathLab Evidence Mentor + + +
+
Offline analysis operations

PathLab Evidence Mentor

Authenticating…
+
+

Data acquisition

+

Qualification campaigns

+

Jobs

+
diff --git a/src/main/resources/evidence-packs/atlas-he-72m-v1.json b/src/main/resources/evidence-packs/atlas-he-72m-v1.json new file mode 100644 index 0000000..eefaa0d --- /dev/null +++ b/src/main/resources/evidence-packs/atlas-he-72m-v1.json @@ -0,0 +1,15 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "atlas-he-72m-v1", + "version": "1", + "capability": "atlas-distillation", + "acceptedStains": ["he", "ihc_dab", "generic_brightfield"], + "preprocessing": {"id": "atlas-feasibility-unstarted-v1", "tilePixels": 512}, + "artifacts": [], + "runtimeCompatibility": {"workerProtocol": "pathlab.model-worker-result/1", "executionProvider": "cuda", "cuda": "12.6", "gpuArchitecture": "sm_61", "requiresExternalWorker": true}, + "licenseLedger": [{"component": "Atlas-H&E candidate", "license": "NOT_YET_CREATED", "revision": "BASELINES_NOT_FROZEN", "permittedUse": "private-research", "redistributable": false, "derivativesAllowed": false}], + "rights": {"license": "Teacher derivative-rights gate not yet passed", "allowedUse": "private-research", "redistributable": false, "derivativesAllowed": false, "reviewedAt": "2026-08-22T00:00:00Z"}, + "resourceEnvelope": {"maxRamMiB": 16384, "maxVramMiB": 4608, "maxSeconds": 1200, "network": false}, + "validation": {"status": "not-evaluable", "modelCard": "docs/model-cards/atlas-he-72m-v1.md", "heldOutEvaluation": "not-evaluable-baseline-packs-and-lawful-teachers-not-frozen"}, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/cell-hovernet-fast-v1.json b/src/main/resources/evidence-packs/cell-hovernet-fast-v1.json new file mode 100644 index 0000000..5bc4eb3 --- /dev/null +++ b/src/main/resources/evidence-packs/cell-hovernet-fast-v1.json @@ -0,0 +1,24 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "cell-hovernet-fast-v1", + "version": "1", + "capability": "cell-morphology", + "acceptedStains": ["he", "ihc_dab"], + "preprocessing": {"id": "hovernet-fast-pannuke-v1", "tilePixels": 512}, + "artifacts": [], + "runtimeCompatibility": { + "workerProtocol": "pathlab.model-worker-result/1", + "executionProvider": "cuda", + "cuda": "12.6", + "gpuArchitecture": "sm_61", + "requiresExternalWorker": true + }, + "licenseLedger": [ + {"component": "HoVer-Net code", "license": "MIT", "revision": "PIN_REQUIRED", "permittedUse": "private-research", "redistributable": false, "derivativesAllowed": true}, + {"component": "PanNuke-derived weights", "license": "CC BY-NC-SA 4.0", "revision": "PIN_REQUIRED", "permittedUse": "private-research", "redistributable": false, "derivativesAllowed": false} + ], + "rights": {"license": "Mixed; see licenseLedger", "allowedUse": "private-research", "redistributable": false, "derivativesAllowed": false, "reviewedAt": "2026-08-22T00:00:00Z"}, + "resourceEnvelope": {"maxRamMiB": 16384, "maxVramMiB": 4608, "maxSeconds": 300, "network": false}, + "validation": {"status": "not-evaluable", "modelCard": "docs/model-cards/cell-hovernet-fast-v1.md", "heldOutEvaluation": "not-evaluable-no-installed-artifact"}, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/cell-od-watershed-v1.json b/src/main/resources/evidence-packs/cell-od-watershed-v1.json new file mode 100644 index 0000000..9a75607 --- /dev/null +++ b/src/main/resources/evidence-packs/cell-od-watershed-v1.json @@ -0,0 +1,34 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "cell-od-watershed-v1", + "version": "1", + "capability": "cell-morphology", + "acceptedStains": [ + "he", + "ihc_dab" + ], + "preprocessing": { + "id": "od-v1", + "tilePixels": 512 + }, + "artifacts": [], + "rights": { + "license": "PathLab deterministic implementation", + "allowedUse": "private-research", + "redistributable": false, + "derivativesAllowed": false, + "reviewedAt": "2026-08-22T00:00:00Z" + }, + "resourceEnvelope": { + "maxRamMiB": 1024, + "maxVramMiB": 0, + "maxSeconds": 120, + "network": false + }, + "validation": { + "status": "experimental", + "modelCard": "docs/model-cards/cell-od-watershed-v1.md", + "heldOutEvaluation": "pending-independent-qualification" + }, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/cell-od-watershed-v2.json b/src/main/resources/evidence-packs/cell-od-watershed-v2.json new file mode 100644 index 0000000..da308f0 --- /dev/null +++ b/src/main/resources/evidence-packs/cell-od-watershed-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.ai-pack/2","packId":"cell-od-watershed-v2","version":"2.0.0","capability":"cell-morphology","scope":"deployment","activationEligible":true,"acceptedStains":["he","ihc_dab","papanicolaou"],"preprocessing":{"id":"od-watershed-v2","tilePixels":512},"artifacts":[],"runtimeCompatibility":{"workerProtocol":"pathlab.model-worker-result/2","executionProvider":"cpu","cuda":"none","gpuArchitecture":"none","requiresExternalWorker":false,"segmentMaxSeconds":120},"licenseLedger":[{"component":"PathLab OD watershed","kind":"code","license":"PathLab repository license","revision":"2.0.0","permittedUse":"private-research","redistributable":false,"derivativesAllowed":true}],"derivativePermissions":{"atlasResearch":true,"atlasClean":true,"reviewedAt":"2026-08-22T00:00:00Z"},"resourceEnvelope":{"lane":"cpu_io","maxRamMiB":1024,"maxVramMiB":0,"maxSeconds":120,"network":false},"qualificationPolicy":{"requiresSignedAttestation":true,"protocolSha256":"dd251775f0abe6a780b2389ee489f18bcec78b9cb80e1facfd7134a05cb0c2a0"},"outputSchema":"pathlab.model-qualification-report/2","markers":[]} diff --git a/src/main/resources/evidence-packs/cell-pathosam-benchmark-v1.json b/src/main/resources/evidence-packs/cell-pathosam-benchmark-v1.json new file mode 100644 index 0000000..e462044 --- /dev/null +++ b/src/main/resources/evidence-packs/cell-pathosam-benchmark-v1.json @@ -0,0 +1,15 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "cell-pathosam-benchmark-v1", + "version": "1", + "capability": "cell-morphology", + "acceptedStains": ["he", "ihc_dab"], + "preprocessing": {"id": "pathosam-uninstalled-v1", "tilePixels": 512}, + "artifacts": [], + "runtimeCompatibility": {"workerProtocol": "pathlab.model-worker-result/1", "executionProvider": "cuda", "cuda": "12.6", "gpuArchitecture": "sm_61", "requiresExternalWorker": true}, + "licenseLedger": [{"component": "PathoSAM code", "license": "MIT", "revision": "PIN_REQUIRED", "permittedUse": "benchmark-only", "redistributable": false, "derivativesAllowed": true}, {"component": "PathoSAM weights", "license": "WEIGHT_LINEAGE_REVIEW_REQUIRED", "revision": "PIN_REQUIRED", "permittedUse": "benchmark-only", "redistributable": false, "derivativesAllowed": false}], + "rights": {"license": "Code and weight rights must pass independently", "allowedUse": "benchmark-only", "redistributable": false, "derivativesAllowed": false, "reviewedAt": "2026-08-22T00:00:00Z"}, + "resourceEnvelope": {"maxRamMiB": 16384, "maxVramMiB": 4608, "maxSeconds": 300, "network": false}, + "validation": {"status": "not-evaluable", "modelCard": "docs/model-cards/cell-pathosam-benchmark-v1.md", "heldOutEvaluation": "not-evaluable-no-installed-rights-cleared-weights"}, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/cytology-descriptive-v2.json b/src/main/resources/evidence-packs/cytology-descriptive-v2.json new file mode 100644 index 0000000..6e75bb3 --- /dev/null +++ b/src/main/resources/evidence-packs/cytology-descriptive-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.ai-pack/2","packId":"cytology-descriptive-v2","version":"2.0.0","capability":"cytology-descriptive","scope":"deployment","activationEligible":true,"acceptedStains":["papanicolaou","generic_brightfield"],"preprocessing":{"id":"papanicolaou-descriptive-v2","tilePixels":512},"artifacts":[],"runtimeCompatibility":{"workerProtocol":"pathlab.model-worker-result/2","executionProvider":"cpu","cuda":"none","gpuArchitecture":"none","requiresExternalWorker":false,"segmentMaxSeconds":120},"licenseLedger":[{"component":"PathLab cytology descriptors","kind":"code","license":"PathLab repository license","revision":"2.0.0","permittedUse":"private-research","redistributable":false,"derivativesAllowed":true}],"derivativePermissions":{"atlasResearch":true,"atlasClean":true,"reviewedAt":"2026-08-22T00:00:00Z"},"resourceEnvelope":{"lane":"cpu_io","maxRamMiB":1024,"maxVramMiB":0,"maxSeconds":120,"network":false},"qualificationPolicy":{"requiresSignedAttestation":true,"protocolSha256":"b354e6c605afc55b5663167453fa2b23738f311d48de5f30293cef0ba26f962a"},"outputSchema":"pathlab.model-qualification-report/2","markers":[]} diff --git a/src/main/resources/evidence-packs/he-dinov2-small-session0-acceptance-v1.json b/src/main/resources/evidence-packs/he-dinov2-small-session0-acceptance-v1.json new file mode 100644 index 0000000..5808b5b --- /dev/null +++ b/src/main/resources/evidence-packs/he-dinov2-small-session0-acceptance-v1.json @@ -0,0 +1,75 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "he-dinov2-small-v1", + "version": "1", + "capability": "he-evidence", + "acceptedStains": [ + "he" + ], + "preprocessing": { + "id": "dinov2-v1", + "tilePixels": 512 + }, + "artifacts": [{ + "name": "model", + "sha256": "ae1e99fcefd534ed978cdeb8326f08030c96e28b7a81ffcbc98a857c84d14be1", + "source": "https://huggingface.co/facebook/dinov2-small/resolve/ed25f3a31f01632728cabb09d1542f84ab7b0056/model.safetensors" + }, { + "name": "config", + "sha256": "1809f83e3bdb1609a501a610ad4a742f4fd8ae44d72ca4aa0df52d1f2ac8628d", + "source": "https://huggingface.co/facebook/dinov2-small/resolve/ed25f3a31f01632728cabb09d1542f84ab7b0056/config.json" + }, { + "name": "preprocessor", + "sha256": "14e780d86fa1861f8751f868d7f45425b5feb55c38ca26f152ca5097ab30f828", + "source": "https://huggingface.co/facebook/dinov2-small/resolve/ed25f3a31f01632728cabb09d1542f84ab7b0056/preprocessor_config.json" + }, { + "name": "worker", + "sha256": "daa33e61d2607e23ecae29e82f274ca05c64f896f9c37061c6ca77ca81aa774b", + "source": "pathlab-local-build:dinov2-worker-v1/windows-x64" + }, { + "name": "workerSource", + "sha256": "9ce24564230eca50eb2008490491af006f7e5f1cfa43a5fa86ec36e0a893ce18", + "source": "pathlab-local-build:dinov2-worker-v1/worker.py" + }, { + "name": "runtime-manifest", + "sha256": "b52c4d80f914c7e6e56d05e594d9478333d763861b5133e3c82af2a6a17fd942", + "source": "pathlab-local-build:torch-2.7.1-cu126-python-3.12/windows-x64" + }], + "runtimeCompatibility": { + "workerProtocol": "pathlab.model-worker-result/1", + "executionProvider": "cuda", + "cuda": "12.6", + "gpuArchitecture": "sm_61", + "requiresExternalWorker": true + }, + "licenseLedger": [{ + "component": "facebook/dinov2-small", + "license": "Apache-2.0", + "revision": "ed25f3a31f01632728cabb09d1542f84ab7b0056", + "permittedUse": "benchmark-only", + "redistributable": false, + "derivativesAllowed": false + }], + "rights": { + "license": "Apache-2.0 upstream; acceptance-only local execution", + "allowedUse": "benchmark-only", + "redistributable": false, + "derivativesAllowed": false, + "reviewedAt": "2026-08-22T00:00:00Z" + }, + "usageLimits": { + "acceptanceOnly": true + }, + "resourceEnvelope": { + "maxRamMiB": 16384, + "maxVramMiB": 4608, + "maxSeconds": 300, + "network": false + }, + "validation": { + "status": "not-evaluable", + "modelCard": "docs/model-cards/he-dinov2-small-session0-acceptance-v1.md", + "heldOutEvaluation": "hardware-acceptance-only-no-quality-claim" + }, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/he-dinov2-small-v1.json b/src/main/resources/evidence-packs/he-dinov2-small-v1.json new file mode 100644 index 0000000..400857d --- /dev/null +++ b/src/main/resources/evidence-packs/he-dinov2-small-v1.json @@ -0,0 +1,76 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "he-dinov2-small-v1", + "version": "1", + "capability": "he-evidence", + "acceptedStains": [ + "he" + ], + "preprocessing": { + "id": "dinov2-v1", + "tilePixels": 512 + }, + "artifacts": [{ + "name": "model", + "sha256": "ae1e99fcefd534ed978cdeb8326f08030c96e28b7a81ffcbc98a857c84d14be1", + "source": "https://huggingface.co/facebook/dinov2-small/resolve/ed25f3a31f01632728cabb09d1542f84ab7b0056/model.safetensors" + }, { + "name": "config", + "sha256": "1809f83e3bdb1609a501a610ad4a742f4fd8ae44d72ca4aa0df52d1f2ac8628d", + "source": "https://huggingface.co/facebook/dinov2-small/resolve/ed25f3a31f01632728cabb09d1542f84ab7b0056/config.json" + }, { + "name": "preprocessor", + "sha256": "14e780d86fa1861f8751f868d7f45425b5feb55c38ca26f152ca5097ab30f828", + "source": "https://huggingface.co/facebook/dinov2-small/resolve/ed25f3a31f01632728cabb09d1542f84ab7b0056/preprocessor_config.json" + }, { + "name": "worker", + "sha256": "daa33e61d2607e23ecae29e82f274ca05c64f896f9c37061c6ca77ca81aa774b", + "source": "pathlab-local-build:dinov2-worker-v1/windows-x64" + }, { + "name": "workerSource", + "sha256": "9ce24564230eca50eb2008490491af006f7e5f1cfa43a5fa86ec36e0a893ce18", + "source": "pathlab-local-build:dinov2-worker-v1/worker.py" + }, { + "name": "runtime-manifest", + "sha256": "b52c4d80f914c7e6e56d05e594d9478333d763861b5133e3c82af2a6a17fd942", + "source": "pathlab-local-build:torch-2.7.1-cu126-python-3.12/windows-x64" + }, { + "name": "qualification-protocol", + "sha256": "d5a46da7a2246afc71fec0bf18989903e8442ed255647b9f044b0538c812df72", + "source": "repo:docs/evidence/he-retrieval-qualification-protocol-v1.md" + }], + "runtimeCompatibility": { + "workerProtocol": "pathlab.model-worker/2", + "executionProvider": "cuda", + "cuda": "12.6", + "gpuArchitecture": "sm_61", + "requiresExternalWorker": true + }, + "licenseLedger": [{ + "component": "facebook/dinov2-small", + "license": "Apache-2.0", + "revision": "ed25f3a31f01632728cabb09d1542f84ab7b0056", + "permittedUse": "private-research", + "redistributable": false, + "derivativesAllowed": false + }], + "rights": { + "license": "Apache-2.0 upstream; exact pinned artifact ledger acquired for private research", + "allowedUse": "private-research", + "redistributable": false, + "derivativesAllowed": false, + "reviewedAt": "2026-08-22T00:00:00Z" + }, + "resourceEnvelope": { + "maxRamMiB": 16384, + "maxVramMiB": 4608, + "maxSeconds": 1200, + "network": false + }, + "validation": { + "status": "experimental", + "modelCard": "docs/model-cards/he-dinov2-small-v1.md", + "heldOutEvaluation": "partial-gi-retrieval-cohort-ready-cross-tissue-patient-mapping-and-ood-incomplete" + }, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/he-gigapath-benchmark-v1.json b/src/main/resources/evidence-packs/he-gigapath-benchmark-v1.json new file mode 100644 index 0000000..579b980 --- /dev/null +++ b/src/main/resources/evidence-packs/he-gigapath-benchmark-v1.json @@ -0,0 +1,33 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "he-gigapath-benchmark-v1", + "version": "1", + "capability": "he-evidence", + "acceptedStains": [ + "he" + ], + "preprocessing": { + "id": "gigapath-v1", + "tilePixels": 512 + }, + "artifacts": [], + "rights": { + "license": "Upstream model card excludes deployed use", + "allowedUse": "benchmark-only", + "redistributable": false, + "derivativesAllowed": false, + "reviewedAt": "2026-08-22T00:00:00Z" + }, + "resourceEnvelope": { + "maxRamMiB": 16384, + "maxVramMiB": 4608, + "maxSeconds": 300, + "network": false + }, + "validation": { + "status": "not-evaluable", + "modelCard": "docs/model-cards/he-gigapath-benchmark-v1.md", + "heldOutEvaluation": "not-evaluable-no-installed-artifact" + }, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/he-hibou-b-v1.json b/src/main/resources/evidence-packs/he-hibou-b-v1.json new file mode 100644 index 0000000..29fa8a7 --- /dev/null +++ b/src/main/resources/evidence-packs/he-hibou-b-v1.json @@ -0,0 +1,48 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "he-hibou-b-v1", + "version": "1", + "capability": "he-evidence", + "acceptedStains": [ + "he" + ], + "preprocessing": { + "id": "hibou-v1", + "tilePixels": 512 + }, + "artifacts": [], + "runtimeCompatibility": { + "workerProtocol": "pathlab.model-worker-result/1", + "executionProvider": "cuda", + "cuda": "12.6", + "gpuArchitecture": "sm_61", + "requiresExternalWorker": true + }, + "licenseLedger": [{ + "component": "histai/hibou-b", + "license": "Apache-2.0 with gated contact-sharing access", + "revision": "USER_ACCEPTANCE_AND_PIN_REQUIRED", + "permittedUse": "private-research", + "redistributable": false, + "derivativesAllowed": false + }], + "rights": { + "license": "Upstream terms require exact artifact review", + "allowedUse": "private-research", + "redistributable": false, + "derivativesAllowed": false, + "reviewedAt": "2026-08-22T00:00:00Z" + }, + "resourceEnvelope": { + "maxRamMiB": 16384, + "maxVramMiB": 4608, + "maxSeconds": 300, + "network": false + }, + "validation": { + "status": "not-evaluable", + "modelCard": "docs/model-cards/he-hibou-b-v1.md", + "heldOutEvaluation": "not-evaluable-no-installed-artifact" + }, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/evidence-packs/ihc-descriptive-v1.json b/src/main/resources/evidence-packs/ihc-descriptive-v1.json new file mode 100644 index 0000000..61ecc51 --- /dev/null +++ b/src/main/resources/evidence-packs/ihc-descriptive-v1.json @@ -0,0 +1,41 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "ihc-descriptive-v1", + "version": "1", + "capability": "ihc-descriptive", + "acceptedStains": [ + "ihc_dab" + ], + "preprocessing": { + "id": "od-v1", + "tilePixels": 512 + }, + "artifacts": [], + "rights": { + "license": "PathLab deterministic implementation", + "allowedUse": "private-research", + "redistributable": false, + "derivativesAllowed": false, + "reviewedAt": "2026-08-22T00:00:00Z" + }, + "resourceEnvelope": { + "maxRamMiB": 1024, + "maxVramMiB": 0, + "maxSeconds": 120, + "network": false + }, + "validation": { + "status": "experimental", + "modelCard": "docs/model-cards/ihc-descriptive-v1.md", + "heldOutEvaluation": "pending-independent-qualification" + }, + "outputSchema": "pathlab.ai-evidence/1", + "markers": [ + "generic", + "er", + "pr", + "ki-67", + "her2", + "pd-l1" + ] +} diff --git a/src/main/resources/evidence-packs/ihc-descriptive-v2.json b/src/main/resources/evidence-packs/ihc-descriptive-v2.json new file mode 100644 index 0000000..61c5d40 --- /dev/null +++ b/src/main/resources/evidence-packs/ihc-descriptive-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.ai-pack/2","packId":"ihc-descriptive-v2","version":"2.0.0","capability":"ihc-descriptive","scope":"deployment","activationEligible":true,"acceptedStains":["ihc_dab"],"preprocessing":{"id":"hdab-relative-v2","tilePixels":512},"artifacts":[],"runtimeCompatibility":{"workerProtocol":"pathlab.model-worker-result/2","executionProvider":"cpu","cuda":"none","gpuArchitecture":"none","requiresExternalWorker":false,"segmentMaxSeconds":120},"licenseLedger":[{"component":"PathLab deterministic H-DAB measurement","kind":"code","license":"PathLab repository license","revision":"2.0.0","permittedUse":"private-research","redistributable":false,"derivativesAllowed":true}],"derivativePermissions":{"atlasResearch":true,"atlasClean":true,"reviewedAt":"2026-08-22T00:00:00Z"},"resourceEnvelope":{"lane":"cpu_io","maxRamMiB":2048,"maxVramMiB":0,"maxSeconds":120,"network":false},"qualificationPolicy":{"requiresSignedAttestation":true,"protocolSha256":"9b2d3299eed5504fe7d89839cacae6ea5232762d6405bdfab261ad78e30b991f"},"outputSchema":"pathlab.model-qualification-report/2","markers":["generic","er","pr","ki-67","her2","pd-l1"]} diff --git a/src/main/resources/evidence-packs/special-stain-descriptive-v2.json b/src/main/resources/evidence-packs/special-stain-descriptive-v2.json new file mode 100644 index 0000000..b2bb4bb --- /dev/null +++ b/src/main/resources/evidence-packs/special-stain-descriptive-v2.json @@ -0,0 +1 @@ +{"schema":"pathlab.ai-pack/2","packId":"special-stain-descriptive-v2","version":"2.0.0","capability":"special-stain-descriptive","scope":"deployment","activationEligible":true,"acceptedStains":["pas","pas_d","trichrome","gms","afb","generic_brightfield"],"preprocessing":{"id":"brightfield-stain-v2","tilePixels":512},"artifacts":[],"runtimeCompatibility":{"workerProtocol":"pathlab.model-worker-result/2","executionProvider":"cpu","cuda":"none","gpuArchitecture":"none","requiresExternalWorker":false,"segmentMaxSeconds":120},"licenseLedger":[{"component":"PathLab special stain descriptors","kind":"code","license":"PathLab repository license","revision":"2.0.0","permittedUse":"private-research","redistributable":false,"derivativesAllowed":true}],"derivativePermissions":{"atlasResearch":true,"atlasClean":true,"reviewedAt":"2026-08-22T00:00:00Z"},"resourceEnvelope":{"lane":"cpu_io","maxRamMiB":1024,"maxVramMiB":0,"maxSeconds":120,"network":false},"qualificationPolicy":{"requiresSignedAttestation":true,"protocolSha256":"d035efcec9812f16464b4e8e59e9d245884a57698626821ab0cf056d05854cee"},"outputSchema":"pathlab.model-qualification-report/2","markers":[]} diff --git a/src/main/resources/evidence-packs/tutor-qwen3-0.6b-int4-v1.json b/src/main/resources/evidence-packs/tutor-qwen3-0.6b-int4-v1.json new file mode 100644 index 0000000..6343232 --- /dev/null +++ b/src/main/resources/evidence-packs/tutor-qwen3-0.6b-int4-v1.json @@ -0,0 +1,15 @@ +{ + "schema": "pathlab.ai-pack/1", + "packId": "tutor-qwen3-0.6b-int4-v1", + "version": "1", + "capability": "grounded-tutor", + "acceptedStains": ["he"], + "preprocessing": {"id": "claim-id-only-v1", "tilePixels": 512}, + "artifacts": [], + "runtimeCompatibility": {"workerProtocol": "pathlab.model-worker-result/1", "executionProvider": "cpu", "cuda": "none", "gpuArchitecture": "none", "requiresExternalWorker": true}, + "licenseLedger": [{"component": "Qwen3-0.6B upstream weights", "license": "Apache-2.0", "revision": "PIN_REQUIRED", "permittedUse": "private-research", "redistributable": false, "derivativesAllowed": true}, {"component": "WebGPU INT4 ONNX browser package", "license": "ARTIFACT_REVIEW_REQUIRED", "revision": "PIN_REQUIRED", "permittedUse": "private-research", "redistributable": false, "derivativesAllowed": false}], + "rights": {"license": "Upstream Apache-2.0; converted artifact ledger pending", "allowedUse": "private-research", "redistributable": false, "derivativesAllowed": false, "reviewedAt": "2026-08-22T00:00:00Z"}, + "resourceEnvelope": {"maxRamMiB": 4096, "maxVramMiB": 0, "maxSeconds": 300, "network": false}, + "validation": {"status": "not-evaluable", "modelCard": "docs/model-cards/tutor-qwen3-0.6b-int4-v1.md", "heldOutEvaluation": "not-evaluable-complete-browser-asset-exceeds-500-mib-cap"}, + "outputSchema": "pathlab.ai-evidence/1" +} diff --git a/src/main/resources/model-workers/dinov2-worker.py b/src/main/resources/model-workers/dinov2-worker.py new file mode 100644 index 0000000..61ab3ed --- /dev/null +++ b/src/main/resources/model-workers/dinov2-worker.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import argparse +import ctypes +import hashlib +import json +import math +import os +from pathlib import Path +import socket +import sys +import time +from datetime import datetime, timezone + + +RESULT_SCHEMA = "pathlab.model-worker-result/1" +PROGRESS_SCHEMA = "pathlab.model-worker-progress/1" +CHECKPOINT_SCHEMA = "pathlab.model-worker-checkpoint/1" +MODEL_FILES = { + "model": "model.safetensors", + "config": "config.json", + "preprocessor": "preprocessor_config.json", + "workerSource": "worker.py", + "runtime-manifest": "runtime-manifest.json", +} + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8-sig") as stream: + value = json.load(stream) + if not isinstance(value, dict): + fail(f"JSON object required: {path.name}") + return value + + +def write_json_atomic(path: Path, value: dict) -> None: + partial = path.with_name(path.name + ".partial") + partial.parent.mkdir(parents=True, exist_ok=True) + with partial.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, sort_keys=True, separators=(",", ":"), allow_nan=False) + stream.write("\n") + os.replace(partial, path) + + +def require_offline(offline: bool) -> None: + if not offline or os.environ.get("PATHLAB_ANALYSIS_NETWORK") != "disabled": + fail("offline analysis contract was not enforced") + if os.environ.get("HF_HUB_OFFLINE") != "1" or os.environ.get("TRANSFORMERS_OFFLINE") != "1": + fail("model library offline flags were not enforced") + + +def disable_network() -> None: + def blocked_socket(*_args, **_kwargs): + raise OSError("network disabled for PathLab analysis") + + socket.socket = blocked_socket + socket.create_connection = blocked_socket + + +def validate_artifacts(install_root: Path, pack: dict) -> None: + artifacts = pack.get("artifacts") + if not isinstance(artifacts, list): + fail("pack artifact ledger is missing") + ledger = {item.get("name"): item.get("sha256") for item in artifacts if isinstance(item, dict)} + for name, relative in MODEL_FILES.items(): + expected = ledger.get(name) + path = (install_root / relative).resolve() + if not isinstance(expected, str) or len(expected) != 64 or not path.is_file(): + fail(f"required artifact is unavailable: {name}") + if sha256(path) != expected: + fail(f"artifact checksum mismatch: {name}") + + runtime = load_json(install_root / "runtime-manifest.json") + if runtime.get("schema") != "pathlab.model-runtime/1": + fail("runtime manifest schema is invalid") + for item in runtime.get("files", []): + if not isinstance(item, dict): + fail("runtime file ledger is invalid") + path = (install_root / str(item.get("path", ""))).resolve() + if install_root not in path.parents or not path.is_file() or sha256(path) != item.get("sha256"): + fail(f"runtime checksum mismatch: {item.get('path', '')}") + + +def working_set_mib() -> float: + class Counters(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.c_ulong), ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t), + ] + counters = Counters() + counters.cb = ctypes.sizeof(counters) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + psapi = ctypes.WinDLL("psapi", use_last_error=True) + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + psapi.GetProcessMemoryInfo.argtypes = [ctypes.c_void_p, ctypes.POINTER(Counters), ctypes.c_ulong] + psapi.GetProcessMemoryInfo.restype = ctypes.c_int + if not psapi.GetProcessMemoryInfo(kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb): + fail("unable to read worker memory") + return counters.PeakWorkingSetSize / (1024 * 1024) + + +def validate_tile_manifest(path: Path, expected_sha: str) -> tuple[Path, dict]: + path = path.resolve() + if not path.is_file() or sha256(path) != expected_sha: + fail("tile-cache manifest checksum does not match") + manifest = load_json(path) + if manifest.get("schema") != "pathlab.tile-cache/1" or manifest.get("tilePixels") != 512: + fail("tile-cache schema is unsupported") + if manifest.get("encoding") != "png" or manifest.get("preprocessingInput") != "rgb-srgb-uint8": + fail("tile-cache pixel contract is unsupported") + tiles = manifest.get("tiles") + if not isinstance(tiles, list) or len(tiles) != 1: + fail("qualification samples must contain exactly one immutable tile") + tile = tiles[0] + relative = Path(str(tile.get("path", ""))) + image_path = (path.parent / relative).resolve() + if relative.is_absolute() or not image_path.is_relative_to(path.parent.resolve()): + fail("tile-cache tile path must be relative") + if not image_path.is_file() or sha256(image_path) != tile.get("sha256"): + fail("tile-cache tile checksum or path is invalid") + return image_path, manifest + + +def load_single_request(request: dict): + from PIL import Image + path, manifest = validate_tile_manifest( + Path(str(request.get("tileCacheManifest", ""))), request.get("tileCacheManifestSha256")) + source = manifest.get("source", {}) + if source.get("sha256") != request.get("sourceSha256") or source.get("slideRevision") != request.get("slideRevision"): + fail("tile cache is stale or belongs to another source revision") + with Image.open(path) as opened: + image = opened.convert("RGB") + image.load() + return [{"id": "request-tile", "image": image, "split": "query", "label": "unknown"}] + + +def load_cohort(request: dict): + from PIL import Image + cohort_path = Path(str(request.get("qualificationCohortManifest", ""))).resolve() + expected = request.get("qualificationCohortManifestSha256") + if not cohort_path.is_file() or sha256(cohort_path) != expected: + fail("qualification cohort checksum does not match") + cohort = load_json(cohort_path) + samples = cohort.get("samples") + if cohort.get("schema") != "pathlab.qualification-cohort/1" or not isinstance(samples, list): + fail("qualification cohort schema is unsupported") + if not 40 <= len(samples) <= 2000: + fail("qualification cohort sample count is invalid") + root = cohort_path.parent.resolve() + loaded = [] + seen = set() + for sample in samples: + if not isinstance(sample, dict) or sample.get("id") in seen: + fail("qualification cohort sample is invalid or duplicated") + seen.add(sample.get("id")) + relative = Path(str(sample.get("tileCacheManifest", ""))) + manifest_path = (root / relative).resolve() + if relative.is_absolute() or not manifest_path.is_relative_to(root): + fail("qualification cohort path escaped its frozen root") + image_path, manifest = validate_tile_manifest(manifest_path, sample.get("tileCacheManifestSha256")) + provenance_path = manifest_path.parent / str(manifest.get("source", {}).get("sampleManifest", "")) + provenance = load_json(provenance_path) + if sha256(provenance_path) != manifest.get("source", {}).get("sampleManifestSha256"): + fail("qualification sample provenance checksum does not match") + if provenance.get("permittedUse") != "private-research": + fail("qualification sample rights are not approved") + with Image.open(image_path) as opened: + image = opened.convert("RGB") + image.load() + loaded.append({ + "id": sample.get("id"), "image": image, "split": sample.get("split"), + "label": sample.get("phenotypeGroup"), "evaluationGroup": sample.get("evaluationGroup"), + }) + return cohort_path, cohort, loaded + + +def bind_legacy_cohort_request(request: dict) -> None: + if request.get("qualificationCohortManifest"): + return + marker = str(request.get("marker", "")) + if not marker.startswith("cohort-") or len(marker) != 71: + return + expected = marker[7:] + if any(character not in "0123456789abcdef" for character in expected): + fail("qualification cohort binding is invalid") + request_path = Path(str(request.get("_requestPath", ""))).resolve() + if len(request_path.parents) < 4: + fail("qualification request path is invalid") + state_root = request_path.parents[3] + cohort_path = state_root / "derived" / "he-dinov2-small-v1" / "nct-crc-gi-20-per-class-v1" / "cohort.json" + if not cohort_path.is_file() or sha256(cohort_path) != expected: + fail("qualification cohort checksum does not match") + request["qualificationCohortManifest"] = str(cohort_path) + request["qualificationCohortManifestSha256"] = expected + + +def histogram_features(samples): + import numpy as np + features = [] + for sample in samples: + pixels = np.asarray(sample["image"], dtype=np.uint8) + parts = [np.histogram(pixels[:, :, channel], bins=16, range=(0, 256))[0].astype(np.float32) + for channel in range(3)] + vector = np.concatenate(parts) + vector /= max(float(np.linalg.norm(vector)), 1e-12) + features.append(vector) + return np.stack(features) + + +def retrieval_metrics(features, samples): + import numpy as np + reference = [i for i, sample in enumerate(samples) if sample["split"] == "reference"] + query = [i for i, sample in enumerate(samples) if sample["split"] == "query"] + if not reference or not query: + fail("qualification cohort reference/query split is incomplete") + labels = sorted({samples[i]["label"] for i in query}) + recalls, ndcgs, rankings = [], [], [] + for label in labels: + label_queries = [i for i in query if samples[i]["label"] == label] + label_recalls, label_ndcgs = [], [] + for index in label_queries: + scores = features[reference] @ features[index] + order = sorted(range(len(reference)), key=lambda offset: (-float(scores[offset]), samples[reference[offset]]["id"])) + ranked = [reference[offset] for offset in order] + rankings.append([samples[item]["id"] for item in ranked]) + relevance = [1 if samples[item]["label"] == label else 0 for item in ranked] + label_recalls.append(1.0 if any(relevance[:5]) else 0.0) + dcg = sum(value / math.log2(rank + 2) for rank, value in enumerate(relevance[:10])) + relevant_total = sum(1 for item in reference if samples[item]["label"] == label) + ideal = sum(1.0 / math.log2(rank + 2) for rank in range(min(10, relevant_total))) + label_ndcgs.append(dcg / ideal if ideal else 0.0) + recalls.append(sum(label_recalls) / len(label_recalls)) + ndcgs.append(sum(label_ndcgs) / len(label_ndcgs)) + return sum(recalls) / len(recalls), sum(ndcgs) / len(ndcgs), rankings + + +def checkpoint(output_path: Path, job_id: str, pack_hash: str, cohort_hash: str, + completed: int, total: int) -> None: + checkpoint_path = output_path.parent / f"checkpoint-{completed:06d}.json" + value = { + "schema": CHECKPOINT_SCHEMA, "jobId": job_id, "packManifestSha256": pack_hash, + "cohortManifestSha256": cohort_hash, "completedUnits": completed, "totalUnits": total, + "containsEmbeddings": False, "updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + write_json_atomic(checkpoint_path, value) + progress = { + "schema": PROGRESS_SCHEMA, "jobId": job_id, "packManifestSha256": pack_hash, + "completedUnits": completed, "totalUnits": total, + "checkpointPath": str(checkpoint_path), "checkpointSha256": sha256(checkpoint_path), + "updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + write_json_atomic(output_path.parent / "progress.json", progress) + + +def model_features(model, processor, samples, output_path, pack_hash, cohort_hash, pass_index): + import numpy as np + import torch + vectors = [] + total = len(samples) * 2 + batch_size = 4 + with torch.inference_mode(): + for offset in range(0, len(samples), batch_size): + batch_samples = samples[offset:offset + batch_size] + pixels = processor(images=[item["image"] for item in batch_samples], return_tensors="pt")["pixel_values"].to("cuda") + output = model(pixel_values=pixels).last_hidden_state[:, 0, :].float().cpu().numpy() + vectors.append(output) + del pixels + completed = pass_index * len(samples) + min(offset + len(batch_samples), len(samples)) + if completed % 16 == 0 or completed == (pass_index + 1) * len(samples): + checkpoint(output_path, output_path.parent.name, pack_hash, cohort_hash, completed, total) + result = np.concatenate(vectors, axis=0) + norms = np.linalg.norm(result, axis=1, keepdims=True) + return result / np.maximum(norms, 1e-12) + + +def infer(install_root: Path, request: dict, output_path: Path, pack_hash: str) -> tuple[list[dict], dict, dict | None]: + import torch + from transformers import AutoImageProcessor, Dinov2Model + disable_network() + if not torch.cuda.is_available() or torch.cuda.get_device_capability(0) != (6, 1): + fail("CUDA sm_61 host is unavailable") + if "sm_61" not in torch.cuda.get_arch_list() or torch.version.cuda != "12.6": + fail("runtime does not contain the pinned Pascal CUDA target") + max_vram = int(os.environ.get("PATHLAB_MAX_VRAM_MIB", "0")) + max_ram = int(os.environ.get("PATHLAB_MAX_RAM_MIB", "0")) + if max_vram <= 0 or max_vram > 4608 or max_ram <= 0 or max_ram > 16384: + fail("resource envelope is invalid") + + torch.manual_seed(0) + torch.cuda.manual_seed_all(0) + torch.backends.cuda.matmul.allow_tf32 = False + torch.backends.cudnn.allow_tf32 = False + torch.backends.cudnn.benchmark = False + torch.use_deterministic_algorithms(True) + torch.cuda.reset_peak_memory_stats() + processor = AutoImageProcessor.from_pretrained(install_root, local_files_only=True, use_fast=False) + model = Dinov2Model.from_pretrained(install_root, local_files_only=True, use_safetensors=True).eval().to("cuda") + started = time.perf_counter() + + qualification = None + if request.get("qualificationCohortManifest"): + cohort_path, cohort, samples = load_cohort(request) + cohort_hash = sha256(cohort_path) + baseline_recall, baseline_ndcg, _ = retrieval_metrics(histogram_features(samples), samples) + first = model_features(model, processor, samples, output_path, pack_hash, cohort_hash, 0) + model_recall, model_ndcg, first_rankings = retrieval_metrics(first, samples) + second = model_features(model, processor, samples, output_path, pack_hash, cohort_hash, 1) + _, _, second_rankings = retrieval_metrics(second, samples) + criteria = cohort.get("acceptanceCriteria", {}) + reasons = list(cohort.get("qualificationReasons", [])) + qualification = { + "schema": "pathlab.he-retrieval-metrics/1", + "cohortId": cohort.get("cohortId"), "cohortManifestSha256": cohort_hash, + "sampleCount": len(samples), "referenceCount": sum(s["split"] == "reference" for s in samples), + "queryCount": sum(s["split"] == "query" for s in samples), + "oodCount": sum(s["split"] == "ood" for s in samples), + "evaluationGroups": sorted({s["evaluationGroup"] for s in samples}), + "baselineId": criteria.get("baselineId"), + "baselineMacroRecallAt5": round(baseline_recall, 9), + "modelMacroRecallAt5": round(model_recall, 9), + "macroRecallAt5Improvement": round(model_recall - baseline_recall, 9), + "baselineMacroNdcgAt10": round(baseline_ndcg, 9), + "modelMacroNdcgAt10": round(model_ndcg, 9), + "macroNdcgAt10Improvement": round(model_ndcg - baseline_ndcg, 9), + "minimumMacroRecallAt5Improvement": criteria.get("minimumMacroRecallAt5Improvement"), + "minimumMacroNdcgAt10Improvement": criteria.get("minimumMacroNdcgAt10Improvement"), + "minimumOodAuRoc": criteria.get("minimumOodAuRoc"), "oodAuRoc": None, + "exactRankingRepeatability": first_rankings == second_rankings, + "cohortStatus": cohort.get("qualificationStatus", "not_evaluable"), + "notEvaluableReasons": reasons, + "embeddingsExported": False, + } + regions = [{"id": "dinov2-cohort-support-1", "stage": "coarse", "kind": "support", + "x": 0, "y": 0, "width": 512, "height": 512, "score": 1.0}] + del first, second + else: + samples = load_single_request(request) + features = model_features(model, processor, samples, output_path, pack_hash, "0" * 64, 0) + regions = [{"id": "dinov2-support-1", "stage": "coarse", "kind": "support", + "x": 0, "y": 0, "width": 512, "height": 512, "score": 1.0}] + del features + + torch.cuda.synchronize() + runtime = { + "device": torch.cuda.get_device_name(0), "cuda": torch.version.cuda, "architecture": "sm_61", + "tiles": len(samples), "batchSize": 4, "elapsedSeconds": round(time.perf_counter() - started, 6), + "peakVramMiB": round(torch.cuda.max_memory_reserved() / (1024 * 1024), 3), + "peakRamMiB": round(working_set_mib(), 3), "analysisNetwork": "disabled", + } + if runtime["peakVramMiB"] > max_vram or runtime["peakRamMiB"] > max_ram: + fail("worker exceeded its declared resource envelope") + del model + torch.cuda.empty_cache() + return regions, runtime, qualification + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--request", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--offline", action="store_true") + parser.add_argument("--resume-checkpoint") + arguments = parser.parse_args() + require_offline(arguments.offline) + install_root = Path(__file__).resolve().parent + request = load_json(Path(arguments.request).resolve()) + request["_requestPath"] = str(Path(arguments.request).resolve()) + bind_legacy_cohort_request(request) + output_path = Path(arguments.output).resolve() + pack_path = Path(str(request.get("packManifest", ""))).resolve() + if not pack_path.is_file(): + fail("pack manifest is unavailable") + pack = load_json(pack_path) + validate_artifacts(install_root, pack) + pack_hash = sha256(pack_path) + regions, runtime, qualification = infer(install_root, request, output_path, pack_hash) + result = {"schema": RESULT_SCHEMA, "status": "completed", "packManifestSha256": pack_hash, + "regions": regions, "runtime": runtime} + if qualification is not None: + result["qualificationMetrics"] = qualification + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("x", encoding="utf-8", newline="\n") as stream: + json.dump(result, stream, sort_keys=True, separators=(",", ":"), allow_nan=False) + stream.write("\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"PathLab DINOv2 worker failed closed: {error}", file=sys.stderr) + raise SystemExit(2) diff --git a/src/main/resources/model-workers/hovernet-worker.py b/src/main/resources/model-workers/hovernet-worker.py new file mode 100644 index 0000000..0e99de5 --- /dev/null +++ b/src/main/resources/model-workers/hovernet-worker.py @@ -0,0 +1,621 @@ +from __future__ import annotations + +import argparse +import ctypes +import hashlib +import json +import os +from pathlib import Path +import socket +import sys +import time +from datetime import datetime, timezone +import xml.etree.ElementTree as ET + + +MICRO_BATCH = 1 +RESULT_SCHEMA = "pathlab.model-worker-result/1" +PROGRESS_SCHEMA = "pathlab.model-worker-progress/1" +CHECKPOINT_SCHEMA = "pathlab.model-worker-checkpoint/1" +COHORT_SCHEMA = "pathlab.cell-qualification-cohort/1" +ORGANS = {"lung", "kidney", "breast", "prostate"} + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8-sig") as stream: + value = json.load(stream) + if not isinstance(value, dict): + fail(f"JSON object required: {path.name}") + return value + + +def write_json_atomic(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + partial = path.with_name(path.name + ".partial") + with partial.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, sort_keys=True, separators=(",", ":"), allow_nan=False) + stream.write("\n") + os.replace(partial, path) + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def require_offline(flag: bool) -> None: + if not flag or os.environ.get("PATHLAB_ANALYSIS_NETWORK") != "disabled": + fail("offline analysis contract was not enforced") + if os.environ.get("HF_HUB_OFFLINE") != "1" or os.environ.get("TRANSFORMERS_OFFLINE") != "1": + fail("model library offline flags were not enforced") + + +def disable_network() -> None: + def blocked_socket(*_args, **_kwargs): + raise OSError("network disabled for PathLab analysis") + + socket.socket = blocked_socket + socket.create_connection = blocked_socket + + +def validate_manifest(install_root: Path, manifest_name: str, schema: str, content_name: str) -> None: + manifest = load_json(install_root / manifest_name) + root = (install_root / content_name).resolve() + if not root.is_relative_to(install_root.resolve()) or not root.is_dir(): + fail(f"{manifest_name} content root is invalid") + if manifest.get("schema") != schema or not isinstance(manifest.get("files"), list): + fail(f"{manifest_name} is invalid") + for item in manifest["files"]: + if not isinstance(item, dict) or set(item) != {"path", "bytes", "sha256"}: + fail(f"{manifest_name} file ledger is invalid") + relative = Path(str(item["path"])) + path = (root / relative).resolve() + if relative.is_absolute() or not path.is_relative_to(root.resolve()) or not path.is_file(): + fail(f"{manifest_name} path is invalid") + if path.stat().st_size != item["bytes"] or sha256(path) != item["sha256"]: + fail(f"{manifest_name} checksum changed") + + +def validate_references(install_root: Path) -> tuple[Path, Path]: + reference = load_json(install_root / "runtime-reference.json") + required = { + "schema", "sharedRuntimePack", "sharedRuntimeVersion", "sharedRuntimeRoot", + "sharedRuntimeManifestSha256", "pythonRelativePath", "candidateId", + "candidateVersion", "candidateRoot", "candidateLedgerSha256", "weightFile", + "weightSha256", "runtimeCopiedIntoCandidate", "analysisNetwork", + } + if set(reference) != required or reference.get("schema") != "pathlab.model-runtime-reference/1": + fail("runtime reference contract is invalid") + if reference.get("runtimeCopiedIntoCandidate") is not False or reference.get("analysisNetwork") != "disabled": + fail("runtime reference isolation is invalid") + runtime_root = Path(str(reference["sharedRuntimeRoot"])).resolve() + candidate_root = Path(str(reference["candidateRoot"])).resolve() + models_root = install_root.parent.parent.resolve() + if not runtime_root.is_relative_to(models_root) or not candidate_root.is_relative_to(models_root): + fail("runtime or candidate reference escaped the model root") + if runtime_root.name != str(reference["sharedRuntimeVersion"]): + fail("shared runtime version binding is invalid") + runtime_manifest = runtime_root / "runtime-manifest.json" + candidate_ledger = candidate_root / "candidate-ledger.json" + weight = candidate_root / str(reference["weightFile"]) + if not runtime_manifest.is_file() or sha256(runtime_manifest) != reference["sharedRuntimeManifestSha256"]: + fail("shared runtime manifest checksum changed") + if not candidate_ledger.is_file() or sha256(candidate_ledger) != reference["candidateLedgerSha256"]: + fail("candidate ledger checksum changed") + if not weight.is_file() or sha256(weight) != reference["weightSha256"]: + fail("HoVer-Net weight checksum changed") + python = (runtime_root / str(reference["pythonRelativePath"])).resolve() + if not python.is_relative_to(runtime_root) or not python.is_file(): + fail("shared Python executable is unavailable") + return python, weight + + +def working_set_mib() -> float: + class Counters(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.c_ulong), ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t), + ] + counters = Counters() + counters.cb = ctypes.sizeof(counters) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + psapi = ctypes.WinDLL("psapi", use_last_error=True) + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + psapi.GetProcessMemoryInfo.argtypes = [ctypes.c_void_p, ctypes.POINTER(Counters), ctypes.c_ulong] + psapi.GetProcessMemoryInfo.restype = ctypes.c_int + if not psapi.GetProcessMemoryInfo(kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb): + fail("unable to read worker memory") + return counters.PeakWorkingSetSize / (1024 * 1024) + + +def starts(size: int, output_size: int) -> list[int]: + if size <= output_size: + return [0] + values = list(range(0, size - output_size + 1, output_size)) + if values[-1] != size - output_size: + values.append(size - output_size) + return values + + +def normalized(value): + import numpy as np + low = float(np.min(value)) + high = float(np.max(value)) + if high - low <= 1.0e-8: + return np.zeros_like(value, dtype=np.float32) + return ((value - low) / (high - low)).astype(np.float32) + + +def postprocess(probability, horizontal, vertical): + import numpy as np + from scipy import ndimage + + foreground = probability >= 0.5 + labels, _ = ndimage.label(foreground) + sizes = np.bincount(labels.ravel()) + keep = sizes >= 10 + keep[0] = False + foreground = keep[labels] + h_sobel = 1.0 - normalized(ndimage.sobel(normalized(horizontal), axis=1)) + v_sobel = 1.0 - normalized(ndimage.sobel(normalized(vertical), axis=0)) + boundary = np.maximum(h_sobel, v_sobel) + boundary = np.maximum(boundary - (~foreground), 0.0) + distance = -ndimage.gaussian_filter((1.0 - boundary) * foreground, sigma=0.5) + marker = foreground & ~(boundary >= 0.4) + marker = ndimage.binary_fill_holes(marker) + marker = ndimage.binary_opening(marker, structure=np.ones((3, 3), dtype=bool)) + markers, _ = ndimage.label(marker) + marker_sizes = np.bincount(markers.ravel()) + valid = marker_sizes >= 10 + valid[0] = False + markers = markers * valid[markers] + markers, _ = ndimage.label(markers > 0) + if int(markers.max()) == 0: + return np.zeros_like(markers, dtype=np.int32) + scaled = normalized(distance) + terrain = np.asarray(np.rint(scaled * 255.0), dtype=np.uint8) + instances = ndimage.watershed_ift(terrain, markers.astype(np.int32)) + instances[~foreground] = 0 + return instances.astype(np.int32) + + +def load_model(weight_path: Path): + import torch + from models.hovernet.net_desc import create_model + + torch.manual_seed(0) + torch.cuda.manual_seed_all(0) + torch.backends.cuda.matmul.allow_tf32 = False + torch.backends.cudnn.allow_tf32 = False + torch.backends.cudnn.benchmark = False + torch.use_deterministic_algorithms(True) + model = create_model(mode="fast", nr_types=5) + checkpoint = torch.load(weight_path, map_location="cpu", weights_only=True) + model.load_state_dict(checkpoint["desc"], strict=True) + return model.eval().to("cuda") + + +def infer(image_path: Path, model): + import numpy as np + from PIL import Image + import torch + + if not torch.cuda.is_available() or torch.cuda.get_device_capability(0) != (6, 1): + fail("CUDA sm_61 host is unavailable") + if torch.version.cuda != "12.6" or "sm_61" not in torch.cuda.get_arch_list(): + fail("runtime does not contain the pinned Pascal CUDA target") + max_vram = int(os.environ.get("PATHLAB_MAX_VRAM_MIB", "0")) + max_ram = int(os.environ.get("PATHLAB_MAX_RAM_MIB", "0")) + if not 0 < max_vram <= 4608 or not 0 < max_ram <= 16384: + fail("resource envelope is invalid") + + with Image.open(image_path) as opened: + image = np.asarray(opened.convert("RGB"), dtype=np.uint8) + if image.shape[0] > 2048 or image.shape[1] > 2048 or image.shape[0] < 64 or image.shape[1] < 64: + fail("probe image geometry is invalid") + + output_size = 164 + border = 46 + height, width = image.shape[:2] + padded = np.pad(image, ((border, border), (border, border), (0, 0)), mode="reflect") + probability = np.zeros((height, width), dtype=np.float32) + horizontal = np.zeros((height, width), dtype=np.float32) + vertical = np.zeros((height, width), dtype=np.float32) + type_scores = np.zeros((height, width, 5), dtype=np.float32) + counts = np.zeros((height, width), dtype=np.float32) + positions = [(y, x) for y in starts(height, output_size) for x in starts(width, output_size)] + started = time.perf_counter() + with torch.inference_mode(): + for offset in range(0, len(positions), MICRO_BATCH): + y, x = positions[offset] + tile = np.ascontiguousarray(padded[y:y + 256, x:x + 256].transpose(2, 0, 1)) + tensor = torch.from_numpy(tile).unsqueeze(0).float().to("cuda") + output = model(tensor) + np_values = torch.softmax(output["np"], dim=1)[0, 1].cpu().numpy() + hv_values = output["hv"][0].cpu().numpy() + tp_values = torch.softmax(output["tp"], dim=1)[0].permute(1, 2, 0).cpu().numpy() + y2 = min(y + output_size, height) + x2 = min(x + output_size, width) + oh, ow = y2 - y, x2 - x + probability[y:y2, x:x2] += np_values[:oh, :ow] + horizontal[y:y2, x:x2] += hv_values[0, :oh, :ow] + vertical[y:y2, x:x2] += hv_values[1, :oh, :ow] + type_scores[y:y2, x:x2] += tp_values[:oh, :ow] + counts[y:y2, x:x2] += 1.0 + del tensor, output + counts = np.maximum(counts, 1.0) + instances = postprocess(probability / counts, horizontal / counts, vertical / counts) + types = np.argmax(type_scores / counts[:, :, None], axis=2) + research_counts = {str(index): 0 for index in range(1, 5)} + for instance_id in range(1, int(instances.max()) + 1): + values = types[instances == instance_id] + if values.size: + selected = int(np.bincount(values, minlength=5).argmax()) + if selected > 0: + research_counts[str(selected)] += 1 + torch.cuda.synchronize() + runtime = { + "device": torch.cuda.get_device_name(0), "cuda": torch.version.cuda, + "architecture": "sm_61", "microBatch": MICRO_BATCH, "tiles": len(positions), + "elapsedSeconds": round(time.perf_counter() - started, 6), + "peakVramMiB": round(torch.cuda.max_memory_reserved() / (1024 * 1024), 3), + "peakRamMiB": round(working_set_mib(), 3), "analysisNetwork": "disabled", + } + if runtime["peakVramMiB"] > max_vram or runtime["peakRamMiB"] > max_ram: + fail("worker exceeded its declared resource envelope") + return instances, research_counts, runtime + + +def required_text(value: dict, name: str) -> str: + result = value.get(name) + if not isinstance(result, str) or not result: + fail(f"cell qualification field is invalid: {name}") + return result + + +def relative_file(root: Path, relative_value: str) -> Path: + relative = Path(relative_value) + path = (root / relative).resolve() + if relative.is_absolute() or not path.is_relative_to(root.resolve()) or not path.is_file(): + fail("cell qualification sample path is invalid") + return path + + +def ground_truth_labels(annotation_path: Path, width: int, height: int): + import numpy as np + from PIL import Image, ImageDraw + + try: + tree = ET.parse(annotation_path) + except ET.ParseError as error: + raise RuntimeError("cell qualification annotation is invalid") from error + labels = Image.new("I", (width, height), 0) + draw = ImageDraw.Draw(labels) + instance_id = 0 + for region in tree.getroot().iter("Region"): + vertices = [] + for vertex in region.iter("Vertex"): + try: + vertices.append((round(float(vertex.attrib["X"])), round(float(vertex.attrib["Y"])))) + except (KeyError, ValueError) as error: + raise RuntimeError("cell qualification annotation is invalid") from error + if len(vertices) >= 3: + instance_id += 1 + draw.polygon(vertices, fill=instance_id) + if instance_id == 0: + fail("cell qualification annotation contains no instances") + return np.asarray(labels, dtype=np.int32) + + +def perimeters(labels, count: int): + import numpy as np + from scipy import ndimage + + result = np.zeros(count + 1, dtype=np.float64) + for instance_id in range(1, count + 1): + mask = labels == instance_id + if not np.any(mask): + continue + eroded = ndimage.binary_erosion(mask, structure=np.ones((3, 3), dtype=bool), border_value=0) + result[instance_id] = float(np.count_nonzero(mask & ~eroded)) + return result + + +def compare_instances(organ: str, truth, predicted: object) -> dict: + import numpy as np + + truth_count = int(truth.max()) + predicted_count = int(predicted.max()) + truth_area = np.bincount(truth.ravel(), minlength=truth_count + 1) + predicted_area = np.bincount(predicted.ravel(), minlength=predicted_count + 1) + factor = predicted_count + 1 + overlap_pixels = (truth > 0) & (predicted > 0) + encoded = truth[overlap_pixels].astype(np.int64) * factor + predicted[overlap_pixels].astype(np.int64) + pairs = [] + if encoded.size: + keys, intersections = np.unique(encoded, return_counts=True) + for key, intersection in zip(keys.tolist(), intersections.tolist()): + truth_id = key // factor + predicted_id = key % factor + union = int(truth_area[truth_id] + predicted_area[predicted_id] - intersection) + iou = intersection / union + if iou >= 0.5: + pairs.append((iou, truth_id, predicted_id, intersection)) + pairs.sort(key=lambda item: (-item[0], item[1], item[2])) + used_truth = set() + used_predicted = set() + matches = [] + for pair in pairs: + if pair[1] not in used_truth and pair[2] not in used_predicted: + matches.append(pair) + used_truth.add(pair[1]) + used_predicted.add(pair[2]) + denominator = len(matches) + 0.5 * (predicted_count - len(matches)) + 0.5 * (truth_count - len(matches)) + pq = sum(pair[0] for pair in matches) / denominator if denominator else 0.0 + dice_denominator = max(truth_count, predicted_count) + dice = (sum(2.0 * pair[3] / (truth_area[pair[1]] + predicted_area[pair[2]]) + for pair in matches) / dice_denominator if dice_denominator else 0.0) + truth_perimeters = perimeters(truth, truth_count) + predicted_perimeters = perimeters(predicted, predicted_count) + biases = [] + for _, truth_id, predicted_id, _ in matches: + area_bias = abs(int(predicted_area[predicted_id]) - int(truth_area[truth_id])) / max(1, int(truth_area[truth_id])) + perimeter_bias = abs(predicted_perimeters[predicted_id] - truth_perimeters[truth_id]) / max(1.0, truth_perimeters[truth_id]) + biases.append((area_bias + perimeter_bias) / 2.0) + return { + "organ": organ, "pq": pq, "dice": dice, + "countError": abs(predicted_count - truth_count) / max(1, truth_count), + "morphometryBias": float(np.median(biases)) if biases else 1.0, + } + + +def validate_cohort(path: Path, expected_hash: str) -> tuple[dict, list[dict]]: + if not path.is_file() or sha256(path) != expected_hash: + fail("cell qualification cohort checksum does not match") + cohort = load_json(path) + samples = cohort.get("samples") + if (cohort.get("schema") != COHORT_SCHEMA or not isinstance(samples, list) + or len(samples) < 4 or cohort.get("sampleCount") != len(samples)): + fail("cell qualification cohort contract is invalid") + return cohort, samples + + +def checkpoint(output_path: Path, job_id: str, pack_hash: str, request_hash: str, + cohort_hash: str, rows: list[dict], failures: list[dict], organs: set[str], + repeatable: bool, total: int) -> None: + completed = len(rows) + len(failures) + checkpoint_path = output_path.parent / f"checkpoint-{completed:06d}.json" + value = { + "schema": CHECKPOINT_SCHEMA, "jobId": job_id, "packManifestSha256": pack_hash, + "requestSha256": request_hash, "cohortManifestSha256": cohort_hash, + "completedUnits": completed, "totalUnits": total, "rows": rows, + "sampleFailures": failures, "observedOrgans": sorted(organs), + "deterministicRepeat": repeatable, "updatedAt": utc_now(), + } + write_json_atomic(checkpoint_path, value) + write_json_atomic(output_path.parent / "progress.json", { + "schema": PROGRESS_SCHEMA, "jobId": job_id, "packManifestSha256": pack_hash, + "completedUnits": completed, "totalUnits": total, + "checkpointPath": str(checkpoint_path), "checkpointSha256": sha256(checkpoint_path), + "updatedAt": utc_now(), + }) + + +def restore_checkpoint(path_value: str | None, job_id: str, pack_hash: str, + request_hash: str, cohort_hash: str, total: int): + if not path_value: + return [], [], set(), True + path = Path(path_value).resolve() + value = load_json(path) + if (value.get("schema") != CHECKPOINT_SCHEMA or value.get("jobId") != job_id + or value.get("packManifestSha256") != pack_hash + or value.get("requestSha256") != request_hash + or value.get("cohortManifestSha256") != cohort_hash + or value.get("totalUnits") != total): + fail("model worker checkpoint identity changed") + rows = value.get("rows") + failures = value.get("sampleFailures") + completed = value.get("completedUnits") + if (not isinstance(rows, list) or not isinstance(failures, list) + or completed != len(rows) + len(failures) or completed < 0 or completed > total): + fail("model worker checkpoint counts are invalid") + organs = value.get("observedOrgans") + if not isinstance(organs, list) or not set(organs).issubset(ORGANS): + fail("model worker checkpoint tissues are invalid") + return rows, failures, set(organs), bool(value.get("deterministicRepeat", False)) + + +def bounded_failure(sample: dict, sample_index: int, error: Exception) -> dict: + sample_id = sample.get("sampleId") + if not isinstance(sample_id, str) or not sample_id or len(sample_id) > 120: + sample_id = f"sample-{sample_index:03d}" + organ = sample.get("organ") if sample.get("organ") in ORGANS else "unknown" + message = str(error).lower() + if "rights or split" in message: + code = "SAMPLE_RIGHTS_OR_SPLIT_INVALID" + elif "checksum changed" in message or "path is invalid" in message: + code = "SAMPLE_CHECKSUM_CHANGED" + elif "geometry is invalid" in message: + code = "SAMPLE_GEOMETRY_INVALID" + elif "annotation" in message: + code = "SAMPLE_ANNOTATION_INVALID" + elif any(value in message for value in ("cuda", "tensor", "shape", "memory", "model")): + code = "MODEL_INFERENCE_FAILED" + else: + code = "SAMPLE_EVALUATION_FAILED" + return {"sampleId": sample_id, "organ": organ, "code": code} + + +def cohort_qualification(request: dict, request_path: Path, output_path: Path, + pack_hash: str, weight: Path, resume_path: str | None): + import numpy as np + import torch + + cohort_path = Path(required_text(request, "qualificationCohortManifest")).resolve() + cohort_hash = required_text(request, "qualificationCohortManifestSha256") + cohort, samples = validate_cohort(cohort_path, cohort_hash) + job_id = output_path.parent.name + request_hash = sha256(request_path) + rows, sample_failures, observed_organs, repeatable = restore_checkpoint( + resume_path, job_id, pack_hash, request_hash, cohort_hash, len(samples)) + completed = len(rows) + len(sample_failures) + started = time.perf_counter() + torch.cuda.reset_peak_memory_stats() + model = load_model(weight) + root = cohort_path.parent + for sample_index, sample in enumerate(samples[completed:], start=completed): + try: + organ = required_text(sample, "organ") + if (organ not in ORGANS or required_text(sample, "split") != "qualification-held-out-test" + or bool(sample.get("patientOverlapWithTraining", True)) + or required_text(sample, "license") != "CC-BY-NC-SA-4.0" + or required_text(sample, "permittedUse") != "private-research-restricted"): + fail("cell qualification sample rights or split is invalid") + image_path = relative_file(root, required_text(sample, "imagePath")) + annotation_path = relative_file(root, required_text(sample, "annotationPath")) + if sha256(image_path) != required_text(sample, "imageSha256") or sha256(annotation_path) != required_text(sample, "annotationSha256"): + fail("cell qualification sample checksum changed") + width = int(sample.get("width", 0)) + height = int(sample.get("height", 0)) + if width < 64 or height < 64 or width * height > 4_194_304: + fail("cell qualification image geometry is invalid") + truth = ground_truth_labels(annotation_path, width, height) + first, _, _ = infer(image_path, model) + second, _, _ = infer(image_path, model) + repeatable = repeatable and np.array_equal(first, second) + rows.append(compare_instances(organ, truth, first)) + observed_organs.add(organ) + except (OSError, RuntimeError, ValueError, KeyError) as error: + sample_failures.append(bounded_failure(sample, sample_index, error)) + checkpoint(output_path, job_id, pack_hash, request_hash, cohort_hash, + rows, sample_failures, observed_organs, repeatable, len(samples)) + elapsed = time.perf_counter() - started + peak_ram = working_set_mib() + peak_vram = torch.cuda.max_memory_reserved() / (1024 * 1024) + if not rows: + fail("no cell qualification region was evaluable") + gates = cohort.get("gates", {}) + required_gates = ["minimumMacroPq", "minimumInstanceDice", "maximumCountError", + "maximumMorphometryBias", "maximumFailedRegionRate"] + if any(not isinstance(gates.get(name), (int, float)) for name in required_gates): + fail("cell qualification gates are invalid") + per_organ = {} + for organ in sorted(ORGANS): + organ_rows = [row for row in rows if row["organ"] == organ] + per_organ[organ] = { + "sampleCount": len(organ_rows), + "macroPq": sum(row["pq"] for row in organ_rows) / len(organ_rows) if organ_rows else 0.0, + "instanceDice": sum(row["dice"] for row in organ_rows) / len(organ_rows) if organ_rows else 0.0, + "countError": sum(row["countError"] for row in organ_rows) / len(organ_rows) if organ_rows else 1.0, + } + integrity_failure_codes = {"SAMPLE_RIGHTS_OR_SPLIT_INVALID", "SAMPLE_CHECKSUM_CHANGED", + "SAMPLE_GEOMETRY_INVALID", "SAMPLE_ANNOTATION_INVALID"} + rights_passed = not any(item["code"] in integrity_failure_codes for item in sample_failures) + cross_tissue = observed_organs == ORGANS + resource_compliant = (peak_ram <= int(os.environ["PATHLAB_MAX_RAM_MIB"]) + and peak_vram <= int(os.environ["PATHLAB_MAX_VRAM_MIB"])) + reasons = [] + if not rights_passed: + reasons.append("CELL_COHORT_RIGHTS_OR_INTEGRITY_FAILED") + if not cross_tissue: + reasons.append("CELL_CROSS_TISSUE_COVERAGE_INCOMPLETE") + if not resource_compliant: + reasons.append("CELL_RESOURCE_ENVELOPE_EXCEEDED") + metrics = { + "schema": "pathlab.cell-instance-metrics/1", "cohortManifestSha256": cohort_hash, + "sampleCount": len(samples), "evaluatedSampleCount": len(rows), + "macroPq": sum(row["pq"] for row in rows) / len(rows), + "instanceDice": sum(row["dice"] for row in rows) / len(rows), + "countError": sum(row["countError"] for row in rows) / len(rows), + "morphometryBias": float(np.median([row["morphometryBias"] for row in rows])), + "failedRegionRate": len(sample_failures) / len(samples), "deterministicRepeat": repeatable, + "crossTissuePerformance": cross_tissue, "rightsAndIntegrityPassed": rights_passed, + "resourceCompliant": resource_compliant, "elapsedSeconds": elapsed, + "peakHeapMiB": peak_ram, "minimumMacroPq": float(gates["minimumMacroPq"]), + "minimumInstanceDice": float(gates["minimumInstanceDice"]), + "maximumCountError": float(gates["maximumCountError"]), + "maximumMorphometryBias": float(gates["maximumMorphometryBias"]), + "maximumFailedRegionRate": float(gates["maximumFailedRegionRate"]), + "sourceIntegrity": str(cohort.get("source", {}).get("integrity", "local-first-acquisition-sha256")), + "upstreamChecksumAvailable": bool(cohort.get("source", {}).get("upstreamChecksumAvailable", False)), + "perOrgan": per_organ, "sampleFailures": sample_failures, + "notEvaluableReasons": reasons, + } + runtime = {"device": torch.cuda.get_device_name(0), "cuda": torch.version.cuda, + "architecture": "sm_61", "microBatch": MICRO_BATCH, "samples": len(samples), + "elapsedSeconds": round(elapsed, 6), "peakVramMiB": round(peak_vram, 3), + "peakRamMiB": round(peak_ram, 3), "analysisNetwork": "disabled"} + return metrics, runtime + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--request", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--offline", action="store_true") + parser.add_argument("--resume-checkpoint") + arguments = parser.parse_args() + require_offline(arguments.offline) + install_root = Path(__file__).resolve().parent + _, weight = validate_references(install_root) + validate_manifest(install_root, "source-manifest.json", "pathlab.model-source-manifest/1", "upstream") + validate_manifest(install_root, "overlay-manifest.json", "pathlab.model-overlay-manifest/1", "overlay") + sys.path.insert(0, str(install_root / "overlay")) + sys.path.insert(0, str(install_root / "upstream")) + import torch # noqa: F401 - fully load local binary dependencies before socket replacement + from scipy import ndimage # noqa: F401 + from models.hovernet.net_desc import create_model # noqa: F401 + disable_network() + request_path = Path(arguments.request).resolve() + request = load_json(request_path) + output = Path(arguments.output).resolve() + if request.get("schema") == "pathlab.hovernet-runtime-probe/1": + import torch + image_path = Path(str(request.get("imagePath", ""))).resolve() + if not image_path.is_file() or sha256(image_path) != request.get("imageSha256"): + fail("probe image checksum changed") + torch.cuda.reset_peak_memory_stats() + model = load_model(weight) + instances, type_counts, runtime = infer(image_path, model) + result = { + "schema": "pathlab.hovernet-runtime-probe-result/1", + "status": "completed", "scope": "runtime-probe-only-not-qualification", + "instanceCount": int(instances.max()), "researchTypeCounts": type_counts, + "runtime": runtime, "activationEligible": False, + } + elif request.get("schema") in {"pathlab.evidence-job/1", "pathlab.evidence-job/2"}: + pack_path = Path(required_text(request, "packManifest")).resolve() + if not pack_path.is_file(): + fail("pack manifest is unavailable") + pack_hash = sha256(pack_path) + metrics, runtime = cohort_qualification( + request, request_path, output, pack_hash, weight, arguments.resume_checkpoint) + result = {"schema": RESULT_SCHEMA, "status": "completed", + "packManifestSha256": pack_hash, "regions": [], + "runtime": runtime, "qualificationMetrics": metrics} + else: + fail("HoVer-Net request schema is unsupported") + write_json_atomic(output, result) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"PathLab model worker failed closed: {error}", file=sys.stderr) + raise SystemExit(2) diff --git a/src/test/java/org/pathlab/forge/evidence/AcquisitionScriptContractTest.java b/src/test/java/org/pathlab/forge/evidence/AcquisitionScriptContractTest.java new file mode 100644 index 0000000..c70be55 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/AcquisitionScriptContractTest.java @@ -0,0 +1,410 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +final class AcquisitionScriptContractTest { + @Test + void zenodoAcquisitionNeverUsesUnsafeHttpResume() throws Exception { + var script = Files.readString(Path.of("scripts/acquire-nct-crc-he.ps1")); + assertFalse(script.contains("--continue-at")); + assertTrue(script.contains("--remove-on-error")); + assertTrue(script.contains("$process.WaitForExit()")); + assertTrue(script.contains("$attempt -le 3")); + assertTrue(script.contains("Move-ToQuarantine $partial")); + assertTrue(script.contains("Downloaded size mismatch")); + assertTrue(script.contains("Checksum validation failed")); + } + + @Test + void nctCrcCohortIsBoundedChecksumPinnedAndFailClosed() throws Exception { + var script = Files.readString(Path.of("scripts/build-nct-crc-dinov2-cohort.ps1")); + assertTrue(script.contains("[int] $SamplesPerClass = 20")); + assertTrue(script.contains("$derivedQuotaBytes = 25GB")); + assertTrue(script.contains("Get-StreamSha256")); + assertTrue(script.contains("PATIENT_LEVEL_PATCH_MAPPING_UNAVAILABLE")); + assertTrue(script.contains("qualificationStatus = 'not_evaluable'")); + assertFalse(script.contains("Expand-Archive")); + } + + @Test + void serviceScriptsUseWindowsPowerShellCompatibleUtf8Writes() throws Exception { + for (var path : new String[] { + "scripts/acquire-dinov2-small.ps1", + "scripts/build-bracs-dinov2-tile-cache.ps1", + "scripts/prepare-all-rounder-campaign.ps1", + "scripts/submit-evidence-job.ps1"}) { + var script = Files.readString(Path.of(path)); + assertFalse(script.contains("utf8NoBOM"), path); + assertTrue(script.contains("UTF8Encoding"), path); + } + } + + @Test + void dinov2CohortWorkerIsChecksumBoundedRepeatableAndEmbeddingFree() throws Exception { + var worker = Files.readString(Path.of( + "src/main/resources/model-workers/dinov2-worker.py")); + var stage = Files.readString(Path.of("scripts/stage-nct-crc-dinov2-retrieval.ps1")); + + assertTrue(worker.contains("pathlab.he-retrieval-metrics/1")); + assertTrue(worker.contains("exactRankingRepeatability")); + assertTrue(worker.contains("embeddingsExported\": False")); + assertTrue(worker.contains("checkpoint(output_path")); + assertTrue(worker.contains("model_features(model, processor, samples, output_path, pack_hash, cohort_hash, 1)")); + assertFalse(worker.contains("result[\"embeddings\"]")); + assertTrue(stage.contains("@($cohort.samples).Count -ne 360")); + assertTrue(stage.contains("marker = \"cohort-$cohortSha\"")); + assertTrue(stage.contains("/v1/qualification-runs")); + } + + @Test + void tcgaLungAcquisitionIsOpenAccessPatientDisjointAndQuotaBounded() throws Exception { + var script = Files.readString(Path.of("scripts/acquire-tcga-lung-he.ps1")); + + assertTrue(script.contains("TCGA-LUAD")); + assertTrue(script.contains("TCGA-LUSC")); + assertTrue(script.contains("access';value=@('open')")); + assertTrue(script.contains("$sourceLimit = 45GB")); + assertTrue(script.contains("$selected.Count -ne 40")); + assertTrue(script.contains("@($selected.patient | Sort-Object -Unique).Count -ne 40")); + assertTrue(script.contains("Get-FileHash -LiteralPath $partial -Algorithm MD5")); + assertTrue(script.contains("Algorithm SHA256")); + assertTrue(script.contains("PathLabTcgaLungAcquisition")); + assertFalse(script.contains("HF_TOKEN")); + } + + @Test + void tcgaLungCohortIsTissueAwareCoordinateBoundAndRightsPinned() throws Exception { + var script = Files.readString(Path.of("scripts/build-tcga-lung-dinov2-cohort.ps1")); + + assertTrue(script.contains("tcga-luad-lusc-he-20x2-v1")); + assertTrue(script.contains("$derivedQuotaBytes = 25GB")); + assertTrue(script.contains("qualification-prepared")); + assertTrue(script.contains("$DerivedUsedBytes")); + assertTrue(script.contains("$reservationPath = \"$outputRoot.reservation\"")); + assertTrue(script.contains("NIH-GDS/NCI-GDC-open-access-policy")); + assertTrue(script.contains("Get-TissueCoordinate")); + assertTrue(script.contains("vipsheader.exe")); + assertTrue(script.contains("vips.exe")); + assertTrue(script.contains("tile-cache.json")); + assertTrue(script.contains("sourceGroup=[string]$row.sourceGroup")); + assertTrue(script.contains("maximumSourceOverlap=0")); + assertTrue(script.contains("LYMPH_NODE_AND_OOD_GROUPS_ABSENT")); + assertFalse(script.contains("Invoke-WebRequest")); + assertFalse(script.contains("Invoke-RestMethod")); + } + + @Test + void tcgaLungRemediationIsLabelBlindQcBoundedAndKeepsFrozenGates() throws Exception { + var script = Files.readString(Path.of("scripts/build-tcga-lung-dinov2-cohort.ps1")); + var protocol = Files.readString(Path.of( + "docs/evidence/tcga-lung-tile-remediation-protocol-v1.md")); + + assertTrue(script.contains("tcga-luad-lusc-lung-20x2-qc-remediation-v1")); + assertTrue(script.contains("Get-TissueCandidates")); + assertTrue(script.contains("Get-TileQcScore")); + assertTrue(script.contains("Select-Object -First 9")); + assertTrue(script.contains("minimumMacroNdcgAt10Improvement=0.03")); + assertFalse(protocol.toLowerCase().contains("lower the")); + assertTrue(protocol.contains("0.03")); + assertTrue(protocol.contains("label-blind")); + assertTrue(protocol.contains("one allowed remediation")); + } + + @Test + void tcgaLungCampaignRequiresNewReportAdapterAndExactCohortBinding() throws Exception { + var script = Files.readString(Path.of("scripts/stage-tcga-lung-dinov2-retrieval.ps1")); + + assertTrue(script.contains("2.1.4")); + assertTrue(script.contains("qualificationCohortManifest = $cohortPath")); + assertTrue(script.contains("qualificationCohortManifestSha256 = $cohortSha")); + assertTrue(script.contains("@($cohort.samples).Count -ne 40")); + assertTrue(script.contains("/v1/qualification-runs")); + assertTrue(script.contains("campaignTargetMet")); + assertTrue(script.contains("ReadAndExecute")); + assertTrue(script.contains("SetSecurityDescriptorSddlForm")); + assertTrue(script.contains("$CohortId")); + assertTrue(script.contains("$ProtocolPath")); + } + + @Test + void bracsBreastCohortIsPatientDisjointChecksumBoundedAndSingleSourceFailClosed() throws Exception { + var script = Files.readString(Path.of("scripts/build-bracs-breast-dinov2-cohort.ps1")); + + assertTrue(script.contains("bracs-roi-breast-retrieval-20x2-v1")); + assertTrue(script.contains("$derivedQuotaBytes = 25GB")); + assertTrue(script.contains("BRACS_RoI_latest_version")); + assertTrue(script.contains("CC-BY-NC-4.0")); + assertTrue(script.contains("$provenance.patient_disjoint")); + assertTrue(script.contains("@($samples | Where-Object split -eq 'reference').Count -ne 40")); + assertTrue(script.contains("@($samples | Where-Object split -eq 'query').Count -ne 40")); + assertTrue(script.contains("SOURCE_HELD_OUT_NOT_MET_SINGLE_BRACS_RELEASE")); + assertTrue(script.contains("LYMPH_NODE_GI_LUNG_AND_OOD_GROUPS_ABSENT")); + assertTrue(script.contains("maximumSourceOverlap=0")); + assertFalse(script.contains("Invoke-WebRequest")); + assertFalse(script.contains("Invoke-RestMethod")); + } + + @Test + void bracsBreastCampaignUsesExactCohortAndInstalledAutonomousRunner() throws Exception { + var script = Files.readString(Path.of("scripts/stage-bracs-breast-dinov2-retrieval.ps1")); + + assertTrue(script.contains("2.1.8")); + assertTrue(script.contains("@($cohort.samples).Count -ne 80")); + assertTrue(script.contains("qualificationCohortManifest=$cohortPath")); + assertTrue(script.contains("qualificationCohortManifestSha256=$cohortSha")); + assertTrue(script.contains("scope='deployment'")); + assertTrue(script.contains("$pack.rights.allowedUse -ne 'private-research'")); + assertTrue(script.contains("/v1/qualification-runs")); + assertTrue(script.contains("campaignTargetMet")); + assertTrue(script.contains("ReadAndExecute")); + assertTrue(script.contains("SetSecurityDescriptorSddlForm")); + } + + @Test + void tcgaCohortBuildCanWaitAutonomouslyForAcquisition() throws Exception { + var script = Files.readString(Path.of("scripts/start-tcga-lung-cohort-build.ps1")); + + assertTrue(script.contains("PathLabTcgaLungCohortBuild")); + assertTrue(script.contains("PathLabTcgaLungCohortRemediation")); + assertTrue(script.contains("build-tcga-lung-dinov2-cohort.ps1")); + assertTrue(script.contains("RepetitionInterval (New-TimeSpan -Minutes 5)")); + assertTrue(script.contains("if ($acquisition.state -ne 'completed')")); + assertTrue(script.contains("Write-Status 'completed'")); + assertTrue(script.contains("SetSecurityDescriptorSddlForm")); + assertTrue(script.indexOf("Set-Acl -LiteralPath $derivedRoot -AclObject $derivedAcl") + < script.indexOf("Test-Path -LiteralPath $cohortPath -PathType Leaf"), + "The protected cohort must not be probed before the temporary read/write handoff"); + } + + @Test + void tumorQuantIhcPreparationIsPinnedBoundedAndNonQualifying() throws Exception { + var script = Files.readString(Path.of("scripts/prepare-tumorquantai-ihc-source.ps1")); + + assertTrue(script.contains("21797920")); + assertTrue(script.contains("10.5281/zenodo.21797920")); + assertTrue(script.contains("cc-by-4.0")); + assertTrue(script.contains("TQA_BreastIHC_manifest_bundle.zip")); + assertTrue(script.contains("e85d64ab3d37f94469a6c507ef3fea88")); + assertTrue(script.contains("2MB"), "Manifest preparation must stay bounded"); + assertTrue(script.contains("manifest_review_required")); + assertTrue(script.contains("private-research-descriptive-only")); + assertTrue(script.contains("ZipFile]::OpenRead")); + assertTrue(script.contains("StartsWith($extractRoot"), "Archive extraction must reject traversal"); + } + + @Test + void tumorQuantIhcSubsetIsAutonomousCaseDisjointAndChecksumBounded() throws Exception { + var script = Files.readString(Path.of("scripts/acquire-tumorquantai-ihc-subset.ps1")); + + assertTrue(script.contains("tumorquantai-breast-ihc-4case-v1")); + assertTrue(script.contains("PathLabTumorQuantIhcAcquisition")); + assertTrue(script.contains("$sourceLimit = 45GB")); + assertTrue(script.contains("TQA_BC_ZPVYVY4T27UKEAYPKAOX.zip")); + assertTrue(script.contains("TQA_BC_KHLKIB6TVKGYE7SUWAOX.zip")); + assertTrue(script.contains("TQA_BC_2R5PE76UT27ESW6WXFR7.zip")); + assertTrue(script.contains("TQA_BC_5QIEJCI66QT6FMUHJ67O.zip")); + assertTrue(script.contains("upstreamSha256")); + assertTrue(script.contains("patientGroup = $item.case")); + assertTrue(script.contains("private-research-descriptive-only")); + assertTrue(script.contains("for ($attempt = 1; $attempt -le 3; $attempt++)")); + assertTrue(script.contains("$requiredBytes += [long]$item.bytes")); + assertTrue(script.contains("service-managed source quota reservation root")); + assertTrue(script.contains("$chunkBytes = 8MB")); + assertTrue(script.contains("'--range'")); + assertTrue(script.contains("TumorQuantAI assembled checksum mismatch")); + assertTrue(script.contains("$null -eq $sum -or $null -eq $sum.Sum")); + assertTrue(script.contains(".Length-eq$expectedChunkBytes){Move-Item")); + assertTrue(script.contains("-Destination $chunk -Force")); + assertFalse(script.contains("$exitCode-ne 0-or-not(Test-Path $chunk)")); + assertFalse(script.contains("RepetitionInterval"), "A failed three-attempt transfer must not restart forever"); + assertFalse(script.contains("--continue-at")); + } + + @Test + void tumorQuantIhcFixtureCampaignWaitsBuildsAndSubmitsAutonomously() throws Exception { + var script = Files.readString(Path.of("scripts/stage-tumorquantai-ihc-fixture-campaign.ps1")); + + assertTrue(script.contains("PathLabTumorQuantIhcFixtureCampaign")); + assertTrue(script.contains("tumorquantai-ihc-descriptive-execution-20260823-v2")); + assertTrue(script.contains("if($acquisition.state-ne'completed')")); + assertTrue(script.contains("RepetitionInterval (New-TimeSpan -Minutes 5)")); + assertTrue(script.contains("@('ER','PR','Ki-67','HER2')")); + assertTrue(script.contains("markerIdentitySource='import-metadata'")); + assertTrue(script.contains("controlsValidated=$false")); + assertTrue(script.contains("SetSecurityDescriptorSddlForm")); + assertTrue(script.contains("/v1/qualification-runs")); + assertTrue(script.contains("private-research-descriptive-only")); + assertTrue(script.contains("crossSectionCellCorrespondence=$false")); + assertTrue(script.contains("-RepositoryRoot `\"$repository`\"")); + assertTrue(script.contains("$taskAction=New-ScheduledTaskAction")); + assertTrue(script.contains("relativePath=\"$sampleId/preview.png\"")); + assertTrue(script.contains("Join-Path $outputRoot $sample.relativePath")); + assertTrue(script.contains("Final IHC fixture is unavailable")); + } + + @Test + void monusacAcquisitionIsOfficialRestrictedResumableAndNonQualifying() throws Exception { + var script = Files.readString(Path.of("scripts/acquire-monusac2020.ps1")); + + assertTrue(script.contains("monusac2020-official-v1")); + assertTrue(script.contains("PathLabMonusacAcquisition")); + assertTrue(script.contains("$sourceLimit = 45GB")); + assertTrue(script.contains("1lxMZaAPSpEHLSxGA9KKMt_r-4S8dwLhq")); + assertTrue(script.contains("545564883L")); + assertTrue(script.contains("1G54vsOdxWY1hG7dzmkeK3r0xz9s-heyQ")); + assertTrue(script.contains("202746703L")); + assertTrue(script.contains("1kdOl3s6uQBRv0nToSIf1dPuceZunzL4N")); + assertTrue(script.contains("19590377L")); + assertTrue(script.contains("CC-BY-NC-SA-4.0")); + assertTrue(script.contains("private-research-restricted")); + assertTrue(script.contains("upstreamChecksumAvailable=$false")); + assertTrue(script.contains("atlasCleanEligible=$false")); + assertTrue(script.contains("Get-FileHash $partial -Algorithm SHA256")); + assertTrue(script.contains("for ($attempt = 1; $attempt -le 3; $attempt++)")); + assertTrue(script.contains("$chunkBytes = 8MB")); + assertTrue(script.contains("'--range'")); + assertTrue(script.contains("Content-Range")); + assertTrue(script.contains("bytes $offset-$end/$($item.bytes)")); + assertTrue(script.contains("source-integrity-frozen-after-first-acquisition")); + assertTrue(script.contains("https://monusac-2020.grand-challenge.org/Data/")); + assertTrue(script.contains("https://drive.usercontent.google.com/download")); + assertFalse(script.contains("RepetitionInterval")); + assertFalse(script.contains("--continue-at")); + } + + @Test + void hoverNetCandidateFreezesCodeAndWeightsWithSeparateRightsAndSharedRuntime() throws Exception { + var script = Files.readString(Path.of("scripts/acquire-hovernet-candidate.ps1")); + + assertTrue(script.contains("hovernet-fast-monusac-v1")); + assertTrue(script.contains("PathLabHoverNetAcquisition")); + assertTrue(script.contains("$modelLimit = 10GB")); + assertTrue(script.contains("https://github.com/vqdang/hover_net.git")); + assertTrue(script.contains("https://codeload.github.com/vqdang/hover_net/zip/")); + assertTrue(script.contains("67e2ce5e3f1a64a2ece77ad1c24233653a9e0901")); + assertTrue(script.contains("13qkxDqv7CUqxN-l5CpeFVmc24mDw6CeV")); + assertTrue(script.contains("hovernet_fast_monusac_type_tf2pytorch.tar")); + assertTrue(script.contains("150995854L")); + assertTrue(script.contains("codeLicense='MIT'")); + assertTrue(script.contains("weightLicense='CC-BY-NC-SA-4.0'")); + assertTrue(script.contains("codeAndWeightLicensesReviewedSeparately=$true")); + assertTrue(script.contains("private-research-restricted")); + assertTrue(script.contains("atlasCleanEligible=$false")); + assertTrue(script.contains("upstreamChecksumAvailable=$false")); + assertTrue(script.contains("Get-FileHash $weightPartial -Algorithm SHA256")); + assertTrue(script.contains("for ($attempt = 1; $attempt -le 3; $attempt++)")); + assertTrue(script.contains("$chunkBytes = 8MB")); + assertTrue(script.contains("'--range'")); + assertTrue(script.contains("Content-Range")); + assertTrue(script.contains("bytes $offset-$end/$weightBytes")); + assertTrue(script.contains("source-integrity-frozen-after-first-acquisition")); + assertTrue(script.contains("sharedRuntimeCandidate='he-dinov2-small-v1/1'")); + assertTrue(script.contains("runtimeCopiedIntoCandidate=$false")); + assertTrue(script.contains("qualificationStatus='not_evaluable'")); + assertTrue(script.contains("Existing HoVer-Net candidate artifact checksum changed")); + assertTrue(script.contains("PathLab-Forge/2.1 research acquisition")); + assertTrue(script.contains("'--retry' '3' '--max-time' '300'")); + assertFalse(script.contains("RepetitionInterval")); + assertFalse(script.contains("--continue-at")); + } + + @Test + void hoverNetAdapterIsPinnedOfflineSharedRuntimeAndResearchOnly() throws Exception { + var build = Files.readString(Path.of("scripts/build-hovernet-fast-monusac-pack.ps1")); + var probe = Files.readString(Path.of("scripts/probe-hovernet-fast-monusac.ps1")); + var stage = Files.readString(Path.of("scripts/stage-hovernet-monusac-qualification.ps1")); + var worker = Files.readString(Path.of("src/main/resources/model-workers/hovernet-worker.py")); + + assertTrue(build.contains("cell-hovernet-fast-monusac-v1")); + assertTrue(build.contains("67e2ce5e3f1a64a2ece77ad1c24233653a9e0901")); + assertTrue(build.contains("5b1c642d9884e20c8fa0b80a6cfef793f483d47eaa5df6183baddc3f57e88a35")); + assertTrue(build.contains("scipy-1.18.1-cp312-cp312-win_amd64.whl")); + assertTrue(build.contains("5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2")); + assertTrue(build.contains("$modelLimit = 10GB")); + assertTrue(build.contains("sharedRuntimePack='he-dinov2-small-v1'")); + assertTrue(build.contains("runtimeCopiedIntoCandidate=$false")); + assertTrue(build.contains("analysisNetwork='disabled'")); + assertFalse(build.contains("pip install")); + + assertTrue(worker.contains("weights_only=True")); + assertTrue(worker.contains("socket.socket = blocked_socket")); + assertTrue(worker.contains("torch.use_deterministic_algorithms(True)")); + assertTrue(worker.contains("create_model(mode=\"fast\", nr_types=5)")); + assertTrue(worker.contains("MICRO_BATCH = 1")); + assertTrue(worker.contains("from scipy import ndimage")); + assertTrue(worker.contains("researchTypeCounts")); + assertTrue(worker.contains("analysisNetwork\": \"disabled")); + assertTrue(worker.contains("pathlab.cell-qualification-cohort/1")); + assertTrue(worker.contains("pathlab.model-worker-progress/1")); + assertTrue(worker.contains("pathlab.model-worker-checkpoint/1")); + assertTrue(worker.contains("--resume-checkpoint")); + assertTrue(worker.contains("qualificationMetrics")); + assertTrue(worker.contains("sampleFailures")); + assertTrue(worker.contains("SAMPLE_CHECKSUM_CHANGED")); + assertTrue(worker.contains("MODEL_INFERENCE_FAILED")); + assertTrue(build.contains("workerProtocol='pathlab.model-worker/2'")); + assertFalse(worker.contains("diagnosis")); + assertFalse(worker.contains("clinicalScore")); + assertFalse(worker.contains("embeddings")); + + assertTrue(probe.contains("PATHLAB_ANALYSIS_NETWORK = 'disabled'")); + assertTrue(probe.contains("HF_HUB_OFFLINE = '1'")); + assertTrue(probe.contains("TRANSFORMERS_OFFLINE = '1'")); + assertTrue(probe.contains("CUBLAS_WORKSPACE_CONFIG = ':4096:8'")); + assertTrue(probe.contains("NVIDIA Quadro P2000")); + assertTrue(probe.contains("runtime-probe-only-not-qualification")); + + assertTrue(stage.contains("monusac-hovernet-fast-heldout-20260824-v2-remediation")); + assertTrue(stage.contains("pathlab.model-worker/2")); + assertTrue(stage.contains("Service 2.1.8")); + assertTrue(stage.contains("qualificationCohortManifestSha256=$cohortSha")); + assertTrue(stage.contains("scope='local-benchmark'")); + assertTrue(stage.contains("SetSecurityDescriptorSddlForm")); + assertTrue(stage.contains("/v1/qualification-runs")); + } + + @Test + void monusacCohortIsPatientHeldOutFourOrganAndChecksumBounded() throws Exception { + var script = Files.readString(Path.of("scripts/build-monusac-cell-cohort.ps1")); + + assertTrue(script.contains("monusac2020-cell-heldout-23-v1")); + assertTrue(script.contains("$derivedQuotaBytes = 25GB")); + assertTrue(script.contains("5b7cbeb34817a8f880d3fddc28391e48d3329a91bf3adcbd131ea149a725cd92")); + assertTrue(script.contains("bcbc38f6bf8b149230c90c29f3428cc7b2b76f8acd7766ce9fc908fc896c2674")); + assertTrue(script.contains("2e4e7774559595a77ed57387ff47177a1da65ccff6154bc2c5d311c8ef02a878")); + assertTrue(script.contains("TCGA-MP-A4T7")); + assertTrue(script.contains("TCGA-A2-A0ES")); + assertTrue(script.contains("patientOverlapWithTraining=$false")); + assertTrue(script.contains("@($samples).Count -ne 23")); + assertTrue(script.contains("CC-BY-NC-SA-4.0")); + assertTrue(script.contains("private-research-restricted")); + assertTrue(script.contains("ZipArchiveMode]::Read")); + assertTrue(script.contains("Find-VipsBinary 'vips.exe'")); + assertTrue(script.contains("Get-FileHash -LiteralPath")); + assertTrue(script.contains("qualification-held-out-test")); + assertFalse(script.contains("Invoke-WebRequest")); + assertFalse(script.contains("Invoke-RestMethod")); + } + + @Test + void monusacCampaignBindsExactCohortAndFrozenCellGates() throws Exception { + var script = Files.readString(Path.of("scripts/stage-monusac-cell-qualification.ps1")); + + assertTrue(script.contains("monusac-od-watershed-heldout-20260824-v1")); + assertTrue(script.contains("qualificationCohortManifest = $cohortPath")); + assertTrue(script.contains("qualificationCohortManifestSha256 = $cohortSha")); + assertTrue(script.contains("minimumMacroPq -ne 0.45")); + assertTrue(script.contains("minimumInstanceDice -ne 0.70")); + assertTrue(script.contains("maximumCountError -ne 0.15")); + assertTrue(script.contains("maximumMorphometryBias -ne 0.10")); + assertTrue(script.contains("maximumFailedRegionRate -ne 0.05")); + assertTrue(script.contains("SetSecurityDescriptorSddlForm")); + assertTrue(script.contains("/v1/qualification-runs")); + assertTrue(script.contains("private-research-restricted")); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/AllRounderPackManifestTest.java b/src/test/java/org/pathlab/forge/evidence/AllRounderPackManifestTest.java new file mode 100644 index 0000000..49dcdf0 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/AllRounderPackManifestTest.java @@ -0,0 +1,37 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; + +final class AllRounderPackManifestTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void deterministicV2CandidatesPinTheirPreregisteredProtocols() throws Exception { + for (var id : java.util.List.of("cell-od-watershed", "ihc-descriptive", + "special-stain-descriptive", "cytology-descriptive")) { + var packPath = Path.of("src/main/resources/evidence-packs/" + id + "-v2.json"); + var protocolPath = Path.of("qualification-protocols/" + id + "-v2.json"); + var pack = EvidencePackManifest.load(packPath); + var raw = JSON.readTree(packPath.toFile()); + // Git may materialize CRLF on Windows. Protocol identities use the + // repository-canonical UTF-8/LF representation on every platform. + var canonicalProtocol = Files.readString(protocolPath, StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + var protocolSha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonicalProtocol.getBytes(StandardCharsets.UTF_8))); + assertEquals(protocolSha, raw.path("qualificationPolicy").path("protocolSha256").asText()); + assertEquals(EvidencePackManifest.SCHEMA_V2, pack.schema()); + assertTrue(pack.activationEligible()); + assertEquals("deployment", pack.scope()); + } + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/AtlasFeasibilityGateTest.java b/src/test/java/org/pathlab/forge/evidence/AtlasFeasibilityGateTest.java new file mode 100644 index 0000000..06b5f6f --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/AtlasFeasibilityGateTest.java @@ -0,0 +1,26 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import java.util.ArrayList; +import org.junit.jupiter.api.Test; + +final class AtlasFeasibilityGateTest { + @Test + void requiresFourHoursImprovementResourceComplianceAndBoundedQuote() { + var segments = new ArrayList(); + for (var index = 0; index < 16; index++) segments.add(new AtlasFeasibilityGate.Segment( + Duration.ofMinutes(15), 8L * 1024 * 1024 * 1024, + 4L * 1024 * 1024 * 1024, 0.1 + index * 0.01, true)); + var decision = AtlasFeasibilityGate.evaluate(segments, Duration.ofDays(6)); + assertEquals("experimental", decision.status()); + assertEquals("LOCAL_PROBE_PASSED_FULL_QUALIFICATION_PENDING", decision.reason()); + assertFalse(decision.paidComputeQuoteAllowed()); + AtlasFeasibilityGate.requireQuote(10_000, 24); + assertThrows(IllegalArgumentException.class, () -> AtlasFeasibilityGate.requireQuote(10_001, 24)); + assertThrows(IllegalArgumentException.class, () -> AtlasFeasibilityGate.requireQuote(9_000, 16)); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/BrightfieldQualificationHarnessTest.java b/src/test/java/org/pathlab/forge/evidence/BrightfieldQualificationHarnessTest.java new file mode 100644 index 0000000..6929d37 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/BrightfieldQualificationHarnessTest.java @@ -0,0 +1,85 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class BrightfieldQualificationHarnessTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void writesChecksumBoundSyntheticQualificationReportAtomically() throws Exception { + var output = temporaryDirectory.resolve("qualification.json"); + var generatedAt = Instant.parse("2026-08-22T09:00:00Z"); + + BrightfieldQualificationHarness.run( + Path.of("src/main/resources/evidence-packs"), output, generatedAt); + + assertTrue(Files.isRegularFile(output)); + assertFalse(Files.exists(output.resolveSibling("qualification.json.partial"))); + var report = JSON.readTree(output.toFile()); + assertEquals("pathlab.model-qualification-report/1", report.path("schema").asText()); + assertEquals(generatedAt.toString(), report.path("generatedAt").asText()); + assertTrue(report.path("syntheticOnly").asBoolean()); + assertTrue(report.path("researchOnly").asBoolean()); + assertTrue(report.path("notDiagnostic").asBoolean()); + assertEquals("experimental", report.path("overallStatus").asText()); + assertEquals(2, report.path("tracks").size()); + assertFalse(report.toString().contains(temporaryDirectory.toString())); + + var cell = track(report, "cell-morphology"); + assertEquals("cell-od-watershed-v1", cell.path("packId").asText()); + assertEquals(64, cell.path("packManifestSha256").asText().length()); + assertEquals("experimental", cell.path("status").asText()); + assertEquals("pass", check(cell, "touching-nuclei-separation").path("outcome").asText()); + assertEquals(2, check(cell, "touching-nuclei-separation").path("observed").asInt()); + assertEquals(2, check(cell, "touching-nuclei-separation").path("required").asInt()); + assertEquals("pass", check(cell, "instance-mask-output").path("outcome").asText()); + + var ihc = track(report, "ihc-descriptive"); + assertEquals("ihc-descriptive-v1", ihc.path("packId").asText()); + assertEquals("experimental", ihc.path("status").asText()); + assertEquals("pass", check(ihc, "generic-dab-area").path("outcome").asText()); + assertEquals("pass", check(ihc, "weak-separation-refusal").path("outcome").asText()); + assertEquals("not_evaluable", check(ihc, "marker-specific-measurement") + .path("outcome").asText()); + } + + @Test + void isDeterministicForTheSamePacksAndTimestamp() throws Exception { + var first = temporaryDirectory.resolve("first.json"); + var second = temporaryDirectory.resolve("second.json"); + var generatedAt = Instant.parse("2026-08-22T09:00:00Z"); + + BrightfieldQualificationHarness.run( + Path.of("src/main/resources/evidence-packs"), first, generatedAt); + BrightfieldQualificationHarness.run( + Path.of("src/main/resources/evidence-packs"), second, generatedAt); + + assertEquals(Files.readString(first), Files.readString(second)); + } + + private static com.fasterxml.jackson.databind.JsonNode track( + com.fasterxml.jackson.databind.JsonNode report, String capability) { + for (var track : report.path("tracks")) { + if (capability.equals(track.path("capability").asText())) return track; + } + throw new AssertionError("Missing track: " + capability); + } + + private static com.fasterxml.jackson.databind.JsonNode check( + com.fasterxml.jackson.databind.JsonNode track, String id) { + for (var check : track.path("checks")) { + if (id.equals(check.path("id").asText())) return check; + } + throw new AssertionError("Missing check: " + id); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/BrightfieldTileAnalyzerTest.java b/src/test/java/org/pathlab/forge/evidence/BrightfieldTileAnalyzerTest.java new file mode 100644 index 0000000..814d8fa --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/BrightfieldTileAnalyzerTest.java @@ -0,0 +1,76 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +final class BrightfieldTileAnalyzerTest { + @Test + void countsBoundedNuclearComponentsAndDescribesDabWithoutClinicalCalls() { + var image = new BufferedImage(32, 24, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 32, 24); + graphics.setColor(new Color(75, 45, 125)); + graphics.fillRect(3, 4, 5, 5); + graphics.fillRect(18, 4, 6, 5); + graphics.setColor(new Color(135, 90, 45)); + graphics.fillRect(8, 14, 12, 6); + graphics.dispose(); + + var result = BrightfieldTileAnalyzer.analyze(image, "ki-67"); + + assertEquals(2, result.cellCount()); + assertTrue(result.meanNucleusAreaPx2() >= 25); + assertTrue(result.meanNucleusPerimeterPx() > 0); + assertTrue(result.meanNucleusEccentricity() >= 0); + assertTrue(result.meanNucleusSolidity() > 0); + assertTrue(result.dabAreaFraction() > 0); + assertTrue(result.meanDabOd() > 0); + assertEquals("nuclear", result.compartment()); + assertTrue(result.researchEstimate()); + assertEquals(2, result.instances().size()); + assertTrue(result.instances().stream().allMatch(instance -> !instance.rle().isEmpty())); + } + + @Test + void markerControlledWatershedSplitsTouchingElongatedNuclearMask() { + var image = new BufferedImage(24, 16, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, 24, 16); + graphics.setColor(new Color(75, 45, 125)); graphics.fillRect(6, 5, 10, 5); + graphics.dispose(); + + var result = BrightfieldTileAnalyzer.analyze(image, "generic"); + + assertEquals(2, result.cellCount()); + assertEquals("cell-1", result.instances().get(0).id()); + assertTrue(result.instances().stream().allMatch(instance -> instance.areaPx2() == 25)); + } + + @Test + void unknownMarkerUsesGenericDescriptiveCompartment() { + var image = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB); + var result = BrightfieldTileAnalyzer.analyze(image, "cdx2"); + assertEquals("generic", result.marker()); + assertEquals("generic-region", result.compartment()); + } + + @Test + void stainQcDefaultsToRelativeOnlyWithoutValidatedControls() { + var image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, 16, 16); + graphics.setColor(new Color(80, 50, 125)); graphics.fillRect(1, 1, 7, 7); + graphics.setColor(new Color(140, 90, 40)); graphics.fillRect(8, 8, 7, 7); + graphics.dispose(); + + var qc = BrightfieldStainQc.inspect(image, false); + + assertEquals("relative_only", qc.calibrationStatus()); + assertTrue(qc.separationScore() > 0); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/CellInstanceQualificationEvaluatorTest.java b/src/test/java/org/pathlab/forge/evidence/CellInstanceQualificationEvaluatorTest.java new file mode 100644 index 0000000..0bce134 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/CellInstanceQualificationEvaluatorTest.java @@ -0,0 +1,92 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class CellInstanceQualificationEvaluatorTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @TempDir + Path temporary; + + @Test + void evaluatesFourOrganHeldOutCohortAgainstFrozenGates() throws Exception { + var samples = JSON.createArrayNode(); + for (var organ : new String[] {"lung", "kidney", "breast", "prostate"}) { + var root = temporary.resolve(organ); + Files.createDirectories(root); + var imagePath = root.resolve("image.png"); + var annotationPath = root.resolve("annotation.xml"); + var image = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 64, 64); + graphics.setColor(new Color(80, 80, 160)); + graphics.fillRect(10, 10, 10, 10); + graphics.dispose(); + ImageIO.write(image, "png", imagePath.toFile()); + Files.writeString(annotationPath, """ + + + + + """); + var sample = samples.addObject(); + sample.put("sampleId", organ + "-1"); + sample.put("patientGroup", organ + "-patient"); + sample.put("slideGroup", organ + "-slide"); + sample.put("organ", organ); + sample.put("split", "qualification-held-out-test"); + sample.put("patientOverlapWithTraining", false); + sample.put("imagePath", temporary.relativize(imagePath).toString()); + sample.put("imageSha256", sha256(imagePath)); + sample.put("annotationPath", temporary.relativize(annotationPath).toString()); + sample.put("annotationSha256", sha256(annotationPath)); + sample.put("width", 64); + sample.put("height", 64); + sample.put("license", "CC-BY-NC-SA-4.0"); + sample.put("permittedUse", "private-research-restricted"); + } + var cohort = JSON.createObjectNode(); + cohort.put("schema", "pathlab.cell-qualification-cohort/1"); + cohort.put("sampleCount", 4); + cohort.set("samples", samples); + cohort.putObject("source").put("integrity", "synthetic-test") + .put("upstreamChecksumAvailable", false); + cohort.putObject("gates").put("minimumMacroPq", 0.45) + .put("minimumInstanceDice", 0.70).put("maximumCountError", 0.15) + .put("maximumMorphometryBias", 0.10).put("maximumFailedRegionRate", 0.05); + var manifest = temporary.resolve("cohort.json"); + JSON.writeValue(manifest.toFile(), cohort); + + var metrics = CellInstanceQualificationEvaluator.evaluate(manifest, sha256(manifest)); + + assertEquals(CellInstanceQualificationEvaluator.SCHEMA, metrics.path("schema").asText()); + assertEquals(1.0, metrics.path("macroPq").asDouble(), 1e-12); + assertEquals(1.0, metrics.path("instanceDice").asDouble(), 1e-12); + assertEquals(0.0, metrics.path("countError").asDouble(), 1e-12); + assertEquals(0.0, metrics.path("morphometryBias").asDouble(), 1e-12); + assertEquals(0.0, metrics.path("failedRegionRate").asDouble(), 1e-12); + assertTrue(metrics.path("deterministicRepeat").asBoolean()); + assertTrue(metrics.path("crossTissuePerformance").asBoolean()); + assertTrue(metrics.path("rightsAndIntegrityPassed").asBoolean()); + assertTrue(metrics.path("resourceCompliant").asBoolean()); + assertTrue(metrics.path("peakHeapMiB").asDouble() < 1024.0); + } + + private static String sha256(Path path) throws Exception { + var digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(Files.readAllBytes(path))); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/DashboardLauncherContractTest.java b/src/test/java/org/pathlab/forge/evidence/DashboardLauncherContractTest.java new file mode 100644 index 0000000..c11e1b4 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/DashboardLauncherContractTest.java @@ -0,0 +1,37 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +final class DashboardLauncherContractTest { + @Test + void launcherPreservesOneTimeFragmentWithAnExplicitBrowserProcess() throws Exception { + var script = Files.readString(Path.of("scripts/open-evidence-dashboard.ps1")); + assertTrue(script.contains("$url = \"$origin/dashboard/#$($session.code)\"")); + assertTrue(script.contains("Start-Process -FilePath $browser -ArgumentList")); + assertTrue(script.contains("'--new-tab'")); + assertFalse(script.contains("Start-Process \"$origin/dashboard/#$($session.code)\"")); + } + + @Test + void failedUpgradeRestoresMarkerFromActualPreviousRuntimeConfig() throws Exception { + var script = Files.readString(Path.of("scripts/evidence-mentor-service.ps1")); + assertTrue(script.contains("$previousRuntimeVersion = $previousVersion")); + assertTrue(script.contains("$previousConfig -match")); + assertTrue(script.contains("Install-FirewallRules (Join-Path $runtimeRoot $previousRuntimeVersion)")); + assertTrue(script.contains("Write-AtomicText $activeVersionPath $previousRuntimeVersion")); + } + + @Test + void installedStateRepairRequiresAgreementBeforeCorrectingTheMarker() throws Exception { + var script = Files.readString(Path.of("scripts/repair-evidence-mentor-installed-state.ps1")); + assertTrue(script.contains("$endpoint.serviceVersion -ne $runtimeVersion")); + assertTrue(script.contains("$status.serviceVersion -ne $runtimeVersion")); + assertTrue(script.contains("open-evidence-dashboard.ps1")); + assertTrue(script.contains("install-evidence-mentor-post-reboot-continuation.ps1")); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/DashboardSessionsTest.java b/src/test/java/org/pathlab/forge/evidence/DashboardSessionsTest.java new file mode 100644 index 0000000..914a88c --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/DashboardSessionsTest.java @@ -0,0 +1,16 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +final class DashboardSessionsTest { + @Test + void codeExpiresAfterSixtySeconds() { + var sessions = new DashboardSessions(); + var now = Instant.parse("2026-08-22T00:00:00Z"); + var code = sessions.create(now); + assertTrue(sessions.exchange(code, now.plusSeconds(61)).isEmpty()); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceArtifactStoreTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceArtifactStoreTest.java new file mode 100644 index 0000000..917948a --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceArtifactStoreTest.java @@ -0,0 +1,66 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceArtifactStoreTest { + @TempDir Path temporaryDirectory; + + @Test + void selectsOnlyExactSlideRevisionAndTerminalSignedArtifact() throws Exception { + write("old", "a".repeat(64), "revision-old", "completed"); + var exact = write("exact", "a".repeat(64), "revision-1", "completed"); + write("running", "a".repeat(64), "revision-1", "running"); + + assertEquals(exact, EvidenceArtifactStore.find( + temporaryDirectory, "a".repeat(64), "revision-1").orElseThrow()); + assertTrue(EvidenceArtifactStore.find( + temporaryDirectory, "b".repeat(64), "revision-1").isEmpty()); + } + + @Test + void rejectsTamperingAndUntrustedEmbeddedKeys() throws Exception { + var exact = write("trusted", "a".repeat(64), "revision-1", "completed"); + var json = new ObjectMapper().readTree(exact.toFile()); + ((com.fasterxml.jackson.databind.node.ObjectNode) json.path("source")) + .put("revision", "revision-tampered"); + new ObjectMapper().writeValue(exact.toFile(), json); + assertTrue(EvidenceArtifactStore.find( + temporaryDirectory, "a".repeat(64), "revision-tampered").isEmpty()); + + var foreignRoot = temporaryDirectory.resolve("foreign"); + var foreignPath = foreignRoot.resolve("artifacts/foreign/evidence.json"); + var unsigned = unsigned("a".repeat(64), "revision-2", "completed"); + new EvidenceBundleWriter(foreignRoot.resolve("signing")).write(foreignPath, unsigned); + var copied = temporaryDirectory.resolve("artifacts/foreign/evidence.json"); + Files.createDirectories(copied.getParent()); + Files.copy(foreignPath, copied); + assertTrue(EvidenceArtifactStore.find( + temporaryDirectory, "a".repeat(64), "revision-2").isEmpty()); + } + + private Path write(String job, String sha, String revision, String status) throws Exception { + var path = temporaryDirectory.resolve("artifacts").resolve(job).resolve("evidence.json"); + new EvidenceBundleWriter(temporaryDirectory.resolve("signing")) + .write(path, unsigned(sha, revision, status)); + return path.toAbsolutePath().normalize(); + } + + private static com.fasterxml.jackson.databind.node.ObjectNode unsigned( + String sha, String revision, String status) { + var root = new ObjectMapper().createObjectNode(); + root.put("schema", EvidenceBundleWriter.SCHEMA); + root.put("researchOnly", true); + root.put("notDiagnostic", true); + root.put("reviewRequired", true); + root.put("status", status); + root.putObject("source").put("slideSha256", sha).put("revision", revision); + return root; + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceBundleWriterTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceBundleWriterTest.java new file mode 100644 index 0000000..1cec4fc --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceBundleWriterTest.java @@ -0,0 +1,58 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceBundleWriterTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void writesStableVerifiableSlideBoundEvidence() throws Exception { + var unsigned = JSON.createObjectNode(); + unsigned.put("schema", "pathlab.ai-evidence/1"); + unsigned.put("bundleId", "bundle-1"); + unsigned.set("source", JSON.valueToTree(java.util.Map.of( + "slideSha256", "a".repeat(64), "revision", "revision-1"))); + unsigned.put("researchOnly", true); + unsigned.put("notDiagnostic", true); + unsigned.put("reviewRequired", true); + var output = temporaryDirectory.resolve("bundle.json"); + var result = new EvidenceBundleWriter(temporaryDirectory.resolve("keys")).write(output, unsigned); + var stored = JSON.readTree(output.toFile()); + assertEquals(result.manifestSha256(), stored.path("manifestSha256").asText()); + var signature = stored.path("signature"); + var verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec( + Base64.getUrlDecoder().decode(signature.path("publicKeyDer").asText())))); + verifier.update(("pathlab.ai-evidence/1\n" + result.manifestSha256()).getBytes(StandardCharsets.UTF_8)); + assertTrue(verifier.verify(Base64.getUrlDecoder().decode(signature.path("value").asText()))); + } + + @Test + void canonicalHashDoesNotDependOnObjectInsertionOrder() throws Exception { + var first = JSON.createObjectNode().put("z", 1).put("a", 2); + var second = JSON.createObjectNode().put("a", 2).put("z", 1); + assertEquals( + EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(first)), + EvidenceBundleWriter.sha256(EvidenceBundleWriter.canonicalBytes(second))); + } + + @Test + void canonicalNumbersMatchViewerContract() throws Exception { + var value = JSON.createObjectNode().put("whole", 100.0).put("zero", -0.0).put("part", 0.125); + assertEquals( + "{\"part\":0.125,\"whole\":100,\"zero\":0}", + new String(EvidenceBundleWriter.canonicalBytes(value), StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceBundleWriterV2Test.java b/src/test/java/org/pathlab/forge/evidence/EvidenceBundleWriterV2Test.java new file mode 100644 index 0000000..b6e8b3a --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceBundleWriterV2Test.java @@ -0,0 +1,32 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceBundleWriterV2Test { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void signsV2WithoutEmbeddingItsOwnTrustAnchor() throws Exception { + var unsigned = JSON.createObjectNode(); + unsigned.put("schema", EvidenceBundleWriterV2.SCHEMA); + unsigned.put("researchOnly", true); + unsigned.put("notDiagnostic", true); + unsigned.put("reviewRequired", true); + unsigned.put("qualificationAttestationSha256", "a".repeat(64)); + unsigned.put("status", "completed"); + unsigned.putObject("source").put("slideSha256", "b".repeat(64)).put("revision", "r1"); + var target = temporaryDirectory.resolve("artifacts/job/evidence.json"); + new EvidenceBundleWriterV2(temporaryDirectory).write(target, unsigned); + var signed = JSON.readTree(target.toFile()); + assertFalse(signed.path("signature").has("publicKeyDer")); + assertTrue(EvidenceArtifactStore.isTrusted( + temporaryDirectory, target, "b".repeat(64), "r1")); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceJobProcessorTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceJobProcessorTest.java new file mode 100644 index 0000000..e3d0481 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceJobProcessorTest.java @@ -0,0 +1,351 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceJobProcessorTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void routesExternalCellCandidatesThroughTheModelWorker() throws Exception { + var external = EvidencePackManifest.load(Path.of( + "src/main/resources/evidence-packs/cell-hovernet-fast-v1.json")); + var deterministic = EvidencePackManifest.load(Path.of( + "src/main/resources/evidence-packs/cell-od-watershed-v2.json")); + + assertTrue(EvidenceJobProcessor.usesExternalModelWorker(external)); + assertEquals(false, EvidenceJobProcessor.usesExternalModelWorker(deterministic)); + } + + @Test + void treatsMalformedRequestJsonAsPermanentValidationFailure() throws Exception { + var request = temporaryDirectory.resolve("malformed-request.json"); + Files.writeString(request, "{not-json"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + + try (var queue = new EvidenceJobQueue(temporaryDirectory.resolve("state/jobs.sqlite3"))) { + queue.submit("job-invalid", request, now); + var claimed = queue.claimNext("worker-1", now, Duration.ofMinutes(2)).orElseThrow(); + + assertThrows(IllegalArgumentException.class, () -> + new EvidenceJobProcessor(queue, temporaryDirectory.resolve("state")) + .process(claimed, "worker-1", now.plusSeconds(1))); + } + } + + @Test + void treatsUndecodablePreviewAsPermanentValidationFailure() throws Exception { + var source = temporaryDirectory.resolve("source.bin"); + Files.writeString(source, "immutable source bytes"); + var preview = temporaryDirectory.resolve("preview.png"); + Files.writeString(preview, "not an image"); + var pack = temporaryDirectory.resolve("pack.json"); + Files.writeString(pack, packJson()); + var request = request(source, preview, pack, "revision-invalid-preview"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + + try (var queue = new EvidenceJobQueue(temporaryDirectory.resolve("state/jobs.sqlite3"))) { + queue.submit("job-invalid-preview", request, now); + var claimed = queue.claimNext("worker-1", now, Duration.ofMinutes(2)).orElseThrow(); + + assertThrows(IllegalArgumentException.class, () -> + new EvidenceJobProcessor(queue, temporaryDirectory.resolve("state")) + .process(claimed, "worker-1", now.plusSeconds(1))); + } + } + + @Test + void signsQcEvidenceAndAbstainsForBlankPreview() throws Exception { + var source = temporaryDirectory.resolve("blank-source.bin"); + Files.writeString(source, "immutable blank source bytes"); + var preview = temporaryDirectory.resolve("blank.png"); + var image = new BufferedImage(16, 12, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 16, 12); + graphics.dispose(); + ImageIO.write(image, "png", preview.toFile()); + var pack = temporaryDirectory.resolve("blank-pack.json"); + Files.writeString(pack, packJson()); + var request = request(source, preview, pack, "revision-blank"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + + try (var queue = new EvidenceJobQueue(temporaryDirectory.resolve("blank-state/jobs.sqlite3"))) { + queue.submit("job-blank", request, now); + var claimed = queue.claimNext("worker-1", now, Duration.ofMinutes(2)).orElseThrow(); + var result = new EvidenceJobProcessor(queue, temporaryDirectory.resolve("blank-state")) + .process(claimed, "worker-1", now.plusSeconds(1)); + + assertEquals(EvidenceJobState.ABSTAINED, result.state()); + var evidence = JSON.readTree(temporaryDirectory + .resolve("blank-state/artifacts/job-blank/evidence.json").toFile()); + assertEquals("abstained", evidence.path("status").asText()); + assertTrue(evidence.path("qc").path("abstentionReasons").size() > 0); + } + } + + @Test + void rejectsHeEvidenceWithoutChecksumBoundTileCache() throws Exception { + var source = temporaryDirectory.resolve("he-source.bin"); + Files.writeString(source, "immutable H&E source bytes"); + var preview = temporaryDirectory.resolve("he-preview.png"); + var image = new BufferedImage(16, 12, BufferedImage.TYPE_INT_RGB); + ImageIO.write(image, "png", preview.toFile()); + var pack = temporaryDirectory.resolve("he-pack.json"); + Files.writeString(pack, hePackJson()); + var request = temporaryDirectory.resolve("he-request.json"); + JSON.writeValue(request.toFile(), java.util.Map.of( + "schema", "pathlab.evidence-job/1", + "sourcePath", source.toString(), + "sourceSha256", sha256(source), + "slideRevision", "revision-he", + "previewPath", preview.toString(), + "sourceWidth", 1600, + "sourceHeight", 1200, + "packManifest", pack.toString(), + "stain", "he", + "marker", "generic")); + var now = Instant.parse("2026-08-22T00:00:00Z"); + + try (var queue = new EvidenceJobQueue(temporaryDirectory.resolve("he-state/jobs.sqlite3"))) { + queue.submit("job-he-no-tiles", request, now); + var claimed = queue.claimNext("worker-1", now, Duration.ofMinutes(2)).orElseThrow(); + assertThrows(IllegalArgumentException.class, () -> + new EvidenceJobProcessor(queue, temporaryDirectory.resolve("he-state")) + .process(claimed, "worker-1", now.plusSeconds(1))); + } + } + + @Test + void completesOfflineDescriptiveIhcJobIntoSignedAtomicEvidence() throws Exception { + var source = temporaryDirectory.resolve("source.bin"); + Files.writeString(source, "immutable source bytes"); + var preview = temporaryDirectory.resolve("preview.png"); + var image = new BufferedImage(16, 12, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 16, 12); + graphics.setColor(new Color(70, 40, 120)); + graphics.fillRect(2, 2, 4, 4); + graphics.setColor(new Color(130, 85, 40)); + graphics.fillRect(8, 4, 6, 5); + graphics.dispose(); + ImageIO.write(image, "png", preview.toFile()); + var pack = temporaryDirectory.resolve("pack.json"); + Files.writeString(pack, packJson()); + var request = temporaryDirectory.resolve("request.json"); + JSON.writeValue(request.toFile(), java.util.Map.ofEntries( + java.util.Map.entry("schema", "pathlab.evidence-job/1"), + java.util.Map.entry("sourcePath", source.toString()), + java.util.Map.entry("sourceSha256", sha256(source)), + java.util.Map.entry("slideRevision", "revision-1"), + java.util.Map.entry("previewPath", preview.toString()), + java.util.Map.entry("sourceWidth", 1600), + java.util.Map.entry("sourceHeight", 1200), + java.util.Map.entry("packManifest", pack.toString()), + java.util.Map.entry("stain", "ihc_dab"), + java.util.Map.entry("marker", "ki-67"), + java.util.Map.entry("markerIdentitySource", "slide-label"))); + + var now = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(temporaryDirectory.resolve("state/jobs.sqlite3"))) { + queue.submit("job-1", request, now); + var claimed = queue.claimNext("worker-1", now, Duration.ofMinutes(2)).orElseThrow(); + var completed = new EvidenceJobProcessor(queue, temporaryDirectory.resolve("state")) + .process(claimed, "worker-1", now.plusSeconds(1)); + + assertEquals(EvidenceJobState.COMPLETED, completed.state()); + var evidence = JSON.readTree( + temporaryDirectory.resolve("state/artifacts/job-1/evidence.json").toFile()); + assertEquals("pathlab.ai-evidence/1", evidence.path("schema").asText()); + assertEquals("ki-67", evidence.path("ihcDescriptors").get(0).path("marker").asText()); + assertEquals("relative_only", evidence.path("ihcDescriptors").get(0) + .path("calibrationStatus").asText()); + assertEquals("marker-aware", evidence.path("ihcDescriptors").get(0) + .path("analysisMode").asText()); + assertEquals("slide-label", evidence.path("ihcDescriptors").get(0) + .path("markerIdentitySource").asText()); + assertEquals("nuclear", evidence.path("ihcDescriptors").get(0) + .path("compartment").asText()); + assertTrue(evidence.path("ihcDescriptors").get(0).path("abstentionReason").isNull()); + assertTrue(evidence.path("ihcDescriptors").get(0).path("measurements") + .has("intensityDistribution")); + assertEquals("od-watershed", evidence.path("ihcDescriptors").get(0) + .path("cellMaskSource").asText()); + assertEquals("od-watershed", evidence.path("cellAggregates").get(0) + .path("algorithm").asText()); + assertTrue(evidence.path("cellAggregates").get(0) + .path("meanNucleusPerimeterPx").asDouble() > 0); + assertTrue(evidence.path("researchOnly").asBoolean()); + assertTrue(evidence.path("provenance").path("offlineAnalysis").asBoolean()); + assertEquals(64, evidence.path("manifestSha256").asText().length()); + } + } + + @Test + void refusesPdL1CompartmentClaimsWithoutReviewedGeometry() throws Exception { + var evidence = processIhcEvidence("pd-l1", "faculty-approved", "faculty-authored", "job-pdl1"); + var descriptor = evidence.path("ihcDescriptors").get(0); + + assertEquals("pd-l1", descriptor.path("markerId").asText()); + assertEquals("generic-fallback", descriptor.path("analysisMode").asText()); + assertEquals("generic-region", descriptor.path("compartment").asText()); + assertEquals("none", descriptor.path("compartmentSource").asText()); + assertEquals("COMPARTMENT_REVIEW_REQUIRED", descriptor.path("abstentionReason").asText()); + assertTrue(evidence.path("qc").path("warnings").toString() + .contains("COMPARTMENT_REVIEW_REQUIRED")); + } + + @Test + void genericIhcOutputIsDescriptiveAndContainsNoClinicalScores() throws Exception { + var evidence = processIhcEvidence("generic", "none", "unknown", "job-generic"); + var descriptor = evidence.path("ihcDescriptors").get(0); + + assertEquals("generic-descriptive", descriptor.path("analysisMode").asText()); + assertEquals("generic-region", descriptor.path("compartment").asText()); + assertTrue(descriptor.path("abstentionReason").isNull()); + for (var prohibited : java.util.List.of( + "positive", "negative", "tps", "cps", "ascoCapCategory", + "diagnosis", "prognosis", "treatmentGuidance")) { + assertTrue(!descriptor.has(prohibited), () -> "Clinical output leaked: " + prohibited); + } + } + + @Test + void resumesSignedIhcPackagingFromRefiningCheckpointAfterRunnerRestart() throws Exception { + var source = temporaryDirectory.resolve("resume-source.bin"); + Files.writeString(source, "immutable resume source bytes"); + var preview = temporaryDirectory.resolve("resume-preview.png"); + var image = new BufferedImage(16, 12, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(new Color(70, 40, 120)); + graphics.fillRect(2, 2, 8, 7); + graphics.dispose(); + ImageIO.write(image, "png", preview.toFile()); + var pack = temporaryDirectory.resolve("resume-pack.json"); + Files.writeString(pack, packJson()); + var request = request(source, preview, pack, "revision-resume"); + var database = temporaryDirectory.resolve("resume-state/jobs.sqlite3"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + + try (var queue = new EvidenceJobQueue(database)) { + queue.submit("job-resume", request, now); + var claimed = queue.claimNext("old-worker", now, Duration.ofMinutes(2)).orElseThrow(); + var running = queue.checkpoint(claimed.id(), "old-worker", EvidenceJobState.RUNNING, + "running", 0.25, "running", now.plusSeconds(1), Duration.ofMinutes(2)); + queue.checkpoint(running.id(), "old-worker", EvidenceJobState.REFINING, + "refining", 0.65, "refining", now.plusSeconds(2), Duration.ofMinutes(2)); + } + + try (var queue = new EvidenceJobQueue(database)) { + queue.recoverOrphanedActiveJobs(now.plusSeconds(3)); + var recovered = queue.claimNext("new-worker", now.plusSeconds(3), Duration.ofMinutes(2)).orElseThrow(); + var completed = new EvidenceJobProcessor(queue, temporaryDirectory.resolve("resume-state")) + .process(recovered, "new-worker", now.plusSeconds(4)); + assertEquals(EvidenceJobState.ABSTAINED, completed.state()); + assertTrue(Files.isRegularFile(temporaryDirectory + .resolve("resume-state/artifacts/job-resume/evidence.json"))); + } + } + + private static String packJson() { + return "{\"schema\":\"pathlab.ai-pack/1\",\"packId\":\"ihc-descriptive-v1\",\"version\":\"1\"," + + "\"capability\":\"ihc-descriptive\",\"acceptedStains\":[\"ihc_dab\"]," + + "\"preprocessing\":{\"id\":\"od-v1\",\"tilePixels\":512},\"artifacts\":[]," + + "\"rights\":{\"license\":\"internal\",\"allowedUse\":\"private-research\"," + + "\"redistributable\":false,\"derivativesAllowed\":false,\"reviewedAt\":\"2026-08-22T00:00:00Z\"}," + + "\"resourceEnvelope\":{\"maxRamMiB\":1024,\"maxVramMiB\":0,\"maxSeconds\":300,\"network\":false}," + + "\"validation\":{\"status\":\"qualified\",\"modelCard\":\"model.md\",\"heldOutEvaluation\":\"evaluation.json\"}," + + "\"outputSchema\":\"pathlab.ai-evidence/1\",\"markers\":[\"generic\",\"ki-67\",\"pd-l1\"]}"; + } + + private static String hePackJson() { + return "{\"schema\":\"pathlab.ai-pack/1\",\"packId\":\"he-fixture-v1\",\"version\":\"1\"," + + "\"capability\":\"he-evidence\",\"acceptedStains\":[\"he\"]," + + "\"preprocessing\":{\"id\":\"he-fixture-v1\",\"tilePixels\":512},\"artifacts\":[]," + + "\"rights\":{\"license\":\"internal\",\"allowedUse\":\"private-research\"," + + "\"redistributable\":false,\"derivativesAllowed\":false," + + "\"reviewedAt\":\"2026-08-22T00:00:00Z\"}," + + "\"resourceEnvelope\":{\"maxRamMiB\":1024,\"maxVramMiB\":0," + + "\"maxSeconds\":300,\"network\":false}," + + "\"validation\":{\"status\":\"experimental\",\"modelCard\":\"model.md\"," + + "\"heldOutEvaluation\":\"fixture.json\"},\"outputSchema\":\"pathlab.ai-evidence/1\"}"; + } + + private Path request(Path source, Path preview, Path pack, String revision) throws Exception { + var request = temporaryDirectory.resolve(revision + "-request.json"); + JSON.writeValue(request.toFile(), java.util.Map.of( + "schema", "pathlab.evidence-job/1", + "sourcePath", source.toString(), + "sourceSha256", sha256(source), + "slideRevision", revision, + "previewPath", preview.toString(), + "sourceWidth", 1600, + "sourceHeight", 1200, + "packManifest", pack.toString(), + "stain", "ihc_dab", + "marker", "generic")); + return request; + } + + private com.fasterxml.jackson.databind.JsonNode processIhcEvidence( + String marker, String compartmentSource, String markerIdentitySource, String jobId) throws Exception { + var source = temporaryDirectory.resolve(jobId + "-source.bin"); + Files.writeString(source, "immutable " + jobId + " source bytes"); + var preview = temporaryDirectory.resolve(jobId + "-preview.png"); + var image = new BufferedImage(16, 12, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 16, 12); + graphics.setColor(new Color(90, 55, 35)); + graphics.fillRect(2, 2, 12, 8); + graphics.dispose(); + ImageIO.write(image, "png", preview.toFile()); + var pack = temporaryDirectory.resolve(jobId + "-pack.json"); + Files.writeString(pack, packJson()); + var request = temporaryDirectory.resolve(jobId + "-request.json"); + JSON.writeValue(request.toFile(), java.util.Map.ofEntries( + java.util.Map.entry("schema", "pathlab.evidence-job/1"), + java.util.Map.entry("sourcePath", source.toString()), + java.util.Map.entry("sourceSha256", sha256(source)), + java.util.Map.entry("slideRevision", "revision-" + jobId), + java.util.Map.entry("previewPath", preview.toString()), + java.util.Map.entry("sourceWidth", 1600), + java.util.Map.entry("sourceHeight", 1200), + java.util.Map.entry("packManifest", pack.toString()), + java.util.Map.entry("stain", "ihc_dab"), + java.util.Map.entry("marker", marker), + java.util.Map.entry("compartmentSource", compartmentSource), + java.util.Map.entry("markerIdentitySource", markerIdentitySource))); + var state = temporaryDirectory.resolve(jobId + "-state"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(state.resolve("jobs.sqlite3"))) { + queue.submit(jobId, request, now); + var claimed = queue.claimNext("worker-1", now, Duration.ofMinutes(2)).orElseThrow(); + var completed = new EvidenceJobProcessor(queue, state) + .process(claimed, "worker-1", now.plusSeconds(1)); + assertEquals(EvidenceJobState.COMPLETED, completed.state()); + } + return JSON.readTree(state.resolve("artifacts").resolve(jobId).resolve("evidence.json").toFile()); + } + + private static String sha256(Path path) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(Files.readAllBytes(path))); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceJobQueueTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceJobQueueTest.java new file mode 100644 index 0000000..800698b --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceJobQueueTest.java @@ -0,0 +1,152 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceJobQueueTest { + @TempDir java.nio.file.Path temporaryDirectory; + + @Test + void resumesExpiredLeaseAcrossRestartAndCompletes() throws Exception { + var database = temporaryDirectory.resolve("jobs.sqlite"); + var request = temporaryDirectory.resolve("request.json"); + Files.writeString(request, "{}"); + var start = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(database)) { + queue.submit("job-1", request, start); + var claimed = queue.claimNext("worker-a", start, Duration.ofSeconds(30)).orElseThrow(); + assertEquals(EvidenceJobState.VALIDATING, claimed.state()); + queue.checkpoint("job-1", "worker-a", EvidenceJobState.RUNNING, "coarse", .2, + "Coarse evidence", start.plusSeconds(1), Duration.ofSeconds(30)); + } + try (var restarted = new EvidenceJobQueue(database)) { + assertTrue(restarted.claimNext("worker-b", start.plusSeconds(32), Duration.ofSeconds(30)).isPresent()); + restarted.checkpoint("job-1", "worker-b", EvidenceJobState.PACKAGING, "packaging", .9, + "Packaging", start.plusSeconds(33), Duration.ofSeconds(30)); + restarted.checkpoint("job-1", "worker-b", EvidenceJobState.COMPLETED, "completed", 1, + "Complete", start.plusSeconds(34), Duration.ofSeconds(30)); + assertEquals(EvidenceJobState.COMPLETED, restarted.find("job-1").orElseThrow().state()); + assertFalse(restarted.claimNext("worker-c", start.plusSeconds(60), Duration.ofSeconds(30)).isPresent()); + } + } + + @Test + void newRunnerBootImmediatelyReclaimsOrphanedActiveLease() throws Exception { + var database = temporaryDirectory.resolve("boot-recovery.sqlite"); + var request = temporaryDirectory.resolve("boot-request.json"); + Files.writeString(request, "{}"); + var start = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(database)) { + queue.submit("boot-job", request, start); + queue.claimNext("old-boot-worker", start, Duration.ofMinutes(5)).orElseThrow(); + } + try (var restarted = new EvidenceJobQueue(database)) { + assertEquals(1, restarted.recoverOrphanedActiveJobs(start.plusSeconds(2))); + var recovered = restarted.claimNext("new-boot-worker", start.plusSeconds(2), + Duration.ofSeconds(45)).orElseThrow(); + assertEquals("boot-job", recovered.id()); + assertEquals("new-boot-worker", recovered.leaseOwner()); + } + } + + @Test + void cancellationFlagSurvivesRestart() throws Exception { + var database = temporaryDirectory.resolve("jobs.sqlite"); + var request = temporaryDirectory.resolve("request.json"); + Files.writeString(request, "{}"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(database)) { + queue.submit("job-2", request, now); + queue.requestCancel("job-2", now.plusSeconds(1)); + } + try (var queue = new EvidenceJobQueue(database)) { + assertTrue(queue.find("job-2").orElseThrow().cancelRequested()); + assertTrue(queue.claimNext("worker", now.plusSeconds(2), Duration.ofSeconds(30)).isEmpty()); + } + } + + @Test + void runnerRestartFinalizesCheckpointSafeActiveCancellation() throws Exception { + var database = temporaryDirectory.resolve("cancel-active.sqlite"); + var request = temporaryDirectory.resolve("cancel-active.json"); + Files.writeString(request, "{}"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(database)) { + queue.submit("cancel-active", request, now); + queue.claimNext("old-worker", now, Duration.ofMinutes(2)).orElseThrow(); + queue.requestCancel("cancel-active", now.plusSeconds(1)); + } + try (var queue = new EvidenceJobQueue(database)) { + assertEquals(1, queue.recoverOrphanedActiveJobs(now.plusSeconds(2))); + assertEquals(EvidenceJobState.CANCELLED, queue.find("cancel-active").orElseThrow().state()); + } + } + + @Test + void separatesExecutionLanesAndRenewsLiveLease() throws Exception { + var database = temporaryDirectory.resolve("jobs.sqlite"); + var gpuRequest = temporaryDirectory.resolve("gpu.json"); + var cpuRequest = temporaryDirectory.resolve("cpu.json"); + Files.writeString(gpuRequest, "{}"); + Files.writeString(cpuRequest, "{}"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(database)) { + queue.submit("gpu-job", gpuRequest, EvidenceExecutionLane.GPU, now); + queue.submit("cpu-job", cpuRequest, EvidenceExecutionLane.CPU_IO, now.plusMillis(1)); + + var cpu = queue.claimNext(EvidenceExecutionLane.CPU_IO, "cpu-worker", now, + Duration.ofSeconds(30)).orElseThrow(); + assertEquals("cpu-job", cpu.id()); + var gpu = queue.claimNext(EvidenceExecutionLane.GPU, "gpu-worker", now, + Duration.ofSeconds(30)).orElseThrow(); + assertEquals("gpu-job", gpu.id()); + + queue.heartbeat("gpu-job", "gpu-worker", 32, 100, + temporaryDirectory.resolve("gpu.checkpoint"), "a".repeat(64), + now.plusSeconds(15), Duration.ofSeconds(30)); + var snapshot = queue.snapshot("gpu-job").orElseThrow(); + assertEquals(32, snapshot.completedUnits()); + assertEquals(100, snapshot.totalUnits()); + assertEquals(now.plusSeconds(45), snapshot.leaseExpiresAt()); + assertEquals(null, snapshot.etaSeconds()); + queue.heartbeat("gpu-job", "gpu-worker", 64, 100, + temporaryDirectory.resolve("gpu.checkpoint"), "a".repeat(64), + now.plusSeconds(30), Duration.ofSeconds(30)); + snapshot = queue.snapshot("gpu-job").orElseThrow(); + assertTrue(snapshot.throughput() > 0); + assertTrue(snapshot.etaSeconds() != null); + } + } + + @Test + void pauseStopsClaimsAndOperatorRetryUsesBoundedDelay() throws Exception { + var database = temporaryDirectory.resolve("jobs.sqlite"); + var request = temporaryDirectory.resolve("request.json"); + Files.writeString(request, "{}"); + var now = Instant.parse("2026-08-22T00:00:00Z"); + try (var queue = new EvidenceJobQueue(database)) { + queue.submit("job", request, EvidenceExecutionLane.CPU_IO, now); + queue.setAcceptingJobs(false, now.plusSeconds(1)); + assertFalse(queue.acceptingJobs()); + assertTrue(queue.claimNext(EvidenceExecutionLane.CPU_IO, "worker", now.plusSeconds(2), + Duration.ofSeconds(30)).isEmpty()); + queue.setAcceptingJobs(true, now.plusSeconds(3)); + var claimed = queue.claimNext(EvidenceExecutionLane.CPU_IO, "worker", now.plusSeconds(4), + Duration.ofSeconds(30)).orElseThrow(); + queue.fail(claimed.id(), "worker", "transient_io", "IO_READ_FAILED", "disk busy", + true, now.plusSeconds(5)); + var retry = queue.snapshot("job").orElseThrow(); + assertEquals(EvidenceJobState.QUEUED, retry.state()); + assertEquals(now.plusSeconds(10), retry.nextRetryAt()); + assertTrue(queue.claimNext(EvidenceExecutionLane.CPU_IO, "worker-2", now.plusSeconds(9), + Duration.ofSeconds(30)).isEmpty()); + } + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceMentorRunnerTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceMentorRunnerTest.java new file mode 100644 index 0000000..62491a3 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceMentorRunnerTest.java @@ -0,0 +1,197 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclEntry; +import java.nio.file.attribute.AclEntryPermission; +import java.nio.file.attribute.AclEntryType; +import java.nio.file.attribute.AclFileAttributeView; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceMentorRunnerTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void endpointPublicationPreservesExplicitWindowsAclEntries() throws Exception { + var state = Files.createDirectories(temporaryDirectory.resolve("acl-state")); + var endpoint = state.resolve("endpoint.json"); + Files.writeString(endpoint, "{}"); + var aclView = Files.getFileAttributeView(endpoint, AclFileAttributeView.class); + Assumptions.assumeTrue(aclView != null, "Windows ACLs are required for this host-specific test"); + var principal = Files.getOwner(endpoint); + var marker = AclEntry.newBuilder() + .setType(AclEntryType.ALLOW) + .setPrincipal(principal) + .setPermissions(AclEntryPermission.READ_ATTRIBUTES) + .build(); + var acl = new java.util.ArrayList<>(aclView.getAcl()); + acl.add(0, marker); + aclView.setAcl(acl); + + try (var runner = EvidenceMentorRunner.start( + state, 0, "test-loopback-token-0123456789abcdef", false)) { + assertTrue(runner.uri("/health").getPort() > 0); + var publishedAcl = Files.getFileAttributeView(endpoint, AclFileAttributeView.class).getAcl(); + assertTrue(publishedAcl.contains(marker)); + } + } + + @Test + void acceptsAuthenticatedLoopbackSubmissionIntoDurableQueue() throws Exception { + var requestFile = temporaryDirectory.resolve("request.json"); + Files.writeString(requestFile, "{}"); + try (var runner = EvidenceMentorRunner.start( + temporaryDirectory.resolve("state"), 0, "test-loopback-token-0123456789abcdef", false)) { + var client = HttpClient.newHttpClient(); + var body = "{\"id\":\"job-http-1\",\"requestPath\":" + + quote(requestFile.toString()) + "}"; + var unauthorized = client.send(HttpRequest.newBuilder(runner.uri("/v1/jobs")) + .POST(HttpRequest.BodyPublishers.ofString(body)).build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(401, unauthorized.statusCode()); + + var invalid = client.send(HttpRequest.newBuilder(runner.uri("/v1/jobs")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .POST(HttpRequest.BodyPublishers.ofString("{}")) .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(422, invalid.statusCode()); + + var accepted = client.send(HttpRequest.newBuilder(runner.uri("/v1/jobs")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(202, accepted.statusCode()); + assertTrue(accepted.body().contains("job-http-1")); + assertTrue(runner.find("job-http-1").isPresent()); + assertEquals("127.0.0.1", runner.uri("/health").getHost()); + + var status = client.send(HttpRequest.newBuilder(runner.uri("/v1/status")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, status.statusCode()); + assertTrue(status.body().contains("networkDisabledForAnalysis")); + assertTrue(status.body().contains("evidence-test")); + assertTrue(status.body().contains("pathlab.evidence-runner-status/2")); + + var jobs = client.send(HttpRequest.newBuilder(runner.uri("/v1/jobs?limit=50")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, jobs.statusCode()); + var notModified = client.send(HttpRequest.newBuilder(runner.uri("/v1/jobs?limit=50")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .header("If-None-Match", jobs.headers().firstValue("ETag").orElseThrow()) + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(304, notModified.statusCode()); + + var endpoint = JSON.readTree(temporaryDirectory.resolve("state/endpoint.json").toFile()); + assertEquals("pathlab.runner-endpoint/1", endpoint.path("schema").asText()); + assertEquals("2.1.8", endpoint.path("serviceVersion").asText()); + assertEquals(runner.uri("/").getPort(), endpoint.path("port").asInt()); + assertTrue(!endpoint.has("token")); + } + } + + @Test + void createsAndReadsDurableQualificationCampaign() throws Exception { + var campaignDirectory = Files.createDirectories(temporaryDirectory.resolve("campaign")); + Files.writeString(campaignDirectory.resolve("request.json"), "{}"); + var manifest = campaignDirectory.resolve("campaign.json"); + Files.writeString(manifest, QualificationCampaignManifestTest.campaign("request.json")); + try (var runner = EvidenceMentorRunner.start( + temporaryDirectory.resolve("state"), 0, "test-loopback-token-0123456789abcdef", false)) { + var client = HttpClient.newHttpClient(); + var body = "{\"manifestPath\":" + quote(manifest.toString()) + "}"; + var accepted = client.send(HttpRequest.newBuilder(runner.uri("/v1/qualification-runs")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(202, accepted.statusCode()); + assertTrue(accepted.body().contains("pathlab.qualification-run/1")); + assertTrue(accepted.body().contains("all-rounder-1")); + + var read = client.send(HttpRequest.newBuilder(runner.uri("/v1/qualification-runs/all-rounder-1")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, read.statusCode()); + assertTrue(read.body().contains("dinov2-small")); + } + } + + @Test + void exposesOnlyBoundedOperationalAcquisitionProgress() throws Exception { + var state = Files.createDirectories(temporaryDirectory.resolve("acquisition-state")); + var acquisition = Files.createDirectories(state.resolve("acquisition/nct-crc-he-1214456")); + Files.writeString(acquisition.resolve("status.json"), """ + {"schema":"pathlab.acquisition-status/1","datasetId":"nct-crc-he-1214456", + "state":"transferring","completedBytes":1048576,"totalBytes":2097152, + "detail":"must not be relayed","networkContext":"interactive-user-acquisition-only", + "analysisNetwork":"disabled","updatedAt":"2026-08-22T14:00:00Z"} + """); + try (var runner = EvidenceMentorRunner.start( + state, 0, "test-loopback-token-0123456789abcdef", false)) { + var response = HttpClient.newHttpClient().send(HttpRequest.newBuilder(runner.uri("/v1/status")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("nct-crc-he-1214456")); + assertTrue(response.body().contains("\"completedBytes\":1048576")); + assertTrue(!response.body().contains("must not be relayed")); + assertTrue(!response.body().contains(acquisition.toString())); + } + } + + @Test + void exchangesSingleUseDashboardCodeAndProtectsMutations() throws Exception { + try (var runner = EvidenceMentorRunner.start( + temporaryDirectory.resolve("state"), 0, "test-loopback-token-0123456789abcdef", false)) { + var client = HttpClient.newBuilder().cookieHandler(new java.net.CookieManager()).build(); + var create = client.send(HttpRequest.newBuilder(runner.uri("/v1/dashboard-sessions")) + .header("Authorization", "Bearer test-loopback-token-0123456789abcdef") + .POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(201, create.statusCode()); + var code = JSON.readTree(create.body()).path("code").asText(); + var exchangeBody = "{\"code\":" + quote(code) + "}"; + var exchange = client.send(HttpRequest.newBuilder(runner.uri("/v1/dashboard-session/exchange")) + .header("Origin", runner.uri("").toString()) + .POST(HttpRequest.BodyPublishers.ofString(exchangeBody)).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, exchange.statusCode()); + var csrf = JSON.readTree(exchange.body()).path("csrfToken").asText(); + assertTrue(exchange.headers().firstValue("set-cookie").orElse("").contains("HttpOnly")); + + var reused = client.send(HttpRequest.newBuilder(runner.uri("/v1/dashboard-session/exchange")) + .header("Origin", runner.uri("").toString()) + .POST(HttpRequest.BodyPublishers.ofString(exchangeBody)).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(401, reused.statusCode()); + + var withoutCsrf = client.send(HttpRequest.newBuilder(runner.uri("/v1/control/pause")) + .header("Origin", runner.uri("").toString()) + .POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(403, withoutCsrf.statusCode()); + var pause = client.send(HttpRequest.newBuilder(runner.uri("/v1/control/pause")) + .header("Origin", runner.uri("").toString()).header("X-PathLab-CSRF", csrf) + .POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, pause.statusCode()); + assertTrue(pause.body().contains("\"acceptingJobs\":false")); + + var dashboard = client.send(HttpRequest.newBuilder(runner.uri("/dashboard/")) + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, dashboard.statusCode()); + assertTrue(dashboard.body().contains("PathLab Evidence Mentor")); + assertTrue(!dashboard.body().contains("https://")); + } + } + + private static String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidencePackManifestTest.java b/src/test/java/org/pathlab/forge/evidence/EvidencePackManifestTest.java new file mode 100644 index 0000000..33f478b --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidencePackManifestTest.java @@ -0,0 +1,154 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidencePackManifestTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void acceptsBoundedPrivateResearchPack() throws Exception { + var path = write("private-research", "qualified", "[\"generic\",\"er\"]", "ihc-descriptive", "[\"ihc_dab\"]"); + var pack = EvidencePackManifest.load(path); + assertEquals("ihc-descriptive-v1", pack.packId()); + assertTrue(pack.pilotEligible()); + assertTrue(pack.markers().contains("generic")); + assertEquals("od-v1", pack.preprocessingId()); + assertEquals(64, pack.sha256().length()); + } + + @Test + void experimentalPackRunsOnlyAsANonIdentifyingQualificationJob() throws Exception { + var pack = EvidencePackManifest.load(write( + "private-research", "experimental", "[\"generic\"]", "ihc-descriptive", "[\"ihc_dab\"]")); + + assertFalse(pack.pilotEligible()); + pack.requireExecutableForJob("qualification-0123abcd"); + assertThrows(IllegalArgumentException.class, () -> pack.requireExecutableForJob("staff-demo")); + assertThrows(IllegalArgumentException.class, () -> pack.requireExecutableForJob("qualification-person-name")); + } + + @Test + void blocksBenchmarkOnlyPackFromPilot() throws Exception { + var pack = EvidencePackManifest.load(write("benchmark-only", "experimental", "[]", "he-evidence", "[\"he\"]")); + assertFalse(pack.pilotEligible()); + assertThrows(IllegalArgumentException.class, pack::requirePilotEligible); + } + + @Test + void acceptanceOnlyGpuPackRunsOnlyForAcceptanceIdsAndNeverBecomesPilotEligible() throws Exception { + var value = manifest("benchmark-only", "not-evaluable", "[]", "he-evidence", "[\"he\"]") + .replace("\"resourceEnvelope\"", "\"usageLimits\":{\"acceptanceOnly\":true},\"runtimeCompatibility\":{" + + "\"workerProtocol\":\"pathlab.model-worker-result/1\",\"executionProvider\":\"cuda\"," + + "\"cuda\":\"12.6\",\"gpuArchitecture\":\"sm_61\",\"requiresExternalWorker\":true}," + + "\"resourceEnvelope\""); + var path = temporaryDirectory.resolve("acceptance-only.json"); + Files.writeString(path, value); + var pack = EvidencePackManifest.load(path); + + assertTrue(pack.acceptanceOnly()); + assertFalse(pack.pilotEligible()); + pack.requireExecutableForJob("acceptance-0123abcd"); + assertThrows(IllegalArgumentException.class, () -> pack.requireExecutableForJob("learner-job")); + } + + @Test + void rejectsAcceptanceOnlyPackThatClaimsExperimentalUse() throws Exception { + var value = manifest("private-research", "experimental", "[]", "he-evidence", "[\"he\"]") + .replace("\"resourceEnvelope\"", "\"usageLimits\":{\"acceptanceOnly\":true},\"runtimeCompatibility\":{" + + "\"workerProtocol\":\"pathlab.model-worker-result/1\",\"executionProvider\":\"cuda\"," + + "\"cuda\":\"12.6\",\"gpuArchitecture\":\"sm_61\",\"requiresExternalWorker\":true}," + + "\"resourceEnvelope\""); + var path = temporaryDirectory.resolve("invalid-acceptance-only.json"); + Files.writeString(path, value); + assertThrows(IllegalArgumentException.class, () -> EvidencePackManifest.load(path)); + } + + @Test + void rejectsNetworkedOrOversizedPack() throws Exception { + var value = manifest("private-research", "qualified", "[]", "he-evidence", "[\"he\"]") + .replace("\"maxVramMiB\":4096", "\"maxVramMiB\":9000") + .replace("\"network\":false", "\"network\":true"); + var path = temporaryDirectory.resolve("invalid.json"); + Files.writeString(path, value); + assertThrows(IllegalArgumentException.class, () -> EvidencePackManifest.load(path)); + } + + @Test + void bundledPilotCatalogActivatesOnlyInstalledLawfulBaselines() throws Exception { + var root = Path.of("src/main/resources/evidence-packs"); + var watershed = EvidencePackManifest.load(root.resolve("cell-od-watershed-v1.json")); + var ihc = EvidencePackManifest.load(root.resolve("ihc-descriptive-v1.json")); + assertFalse(watershed.pilotEligible()); + assertFalse(ihc.pilotEligible()); + watershed.requireExecutableForJob("qualification-0123abcd"); + ihc.requireExecutableForJob("qualification-0123abcd"); + var dino = EvidencePackManifest.load(root.resolve("he-dinov2-small-v1.json")); + assertFalse(dino.pilotEligible()); + assertEquals(EvidencePackManifest.ValidationStatus.EXPERIMENTAL, dino.validationStatus()); + assertEquals("ed25f3a31f01632728cabb09d1542f84ab7b0056", + dino.licenseLedger().get(0).revision()); + assertEquals(7, dino.artifacts().size()); + assertEquals("ae1e99fcefd534ed978cdeb8326f08030c96e28b7a81ffcbc98a857c84d14be1", + dino.artifacts().get(0).sha256()); + assertEquals("daa33e61d2607e23ecae29e82f274ca05c64f896f9c37061c6ca77ca81aa774b", + dino.artifacts().stream().filter(item -> "worker".equals(item.name())) + .findFirst().orElseThrow().sha256()); + assertFalse(EvidencePackManifest.load(root.resolve("he-hibou-b-v1.json")).pilotEligible()); + var session0 = EvidencePackManifest.load(root.resolve("he-dinov2-small-session0-acceptance-v1.json")); + assertTrue(session0.acceptanceOnly()); + assertFalse(session0.pilotEligible()); + session0.requireExecutableForJob("acceptance-0123abcd"); + var hoverNet = EvidencePackManifest.load(root.resolve("cell-hovernet-fast-v1.json")); + assertFalse(hoverNet.pilotEligible()); + assertEquals("cuda", hoverNet.runtimeCompatibility().executionProvider()); + assertEquals(2, hoverNet.licenseLedger().size()); + assertDoesNotThrow(() -> hoverNet.requireExecutableForJob("qualification-0123abcd")); + assertFalse(EvidencePackManifest.load(root.resolve("he-gigapath-benchmark-v1.json")).pilotEligible()); + } + + @Test + void everyBundledPackResolvesItsModelCard() throws Exception { + var root = Path.of("src/main/resources/evidence-packs"); + try (var manifests = Files.list(root)) { + for (var manifest : manifests.filter(path -> path.toString().endsWith(".json")).toList()) { + var rootNode = JSON.readTree(manifest.toFile()); + if (EvidencePackManifest.SCHEMA_V2.equals(rootNode.path("schema").asText())) { + assertTrue(rootNode.path("qualificationPolicy").path("protocolSha256") + .asText().matches("[a-f0-9]{64}")); + continue; + } + var card = rootNode.path("validation").path("modelCard").asText(); + assertTrue(!card.isBlank() && Files.isRegularFile(Path.of(card)), + () -> manifest.getFileName() + " references a missing model card: " + card); + } + } + } + + private Path write(String use, String status, String markers, String capability, String stains) throws Exception { + var path = temporaryDirectory.resolve(java.util.UUID.randomUUID() + ".json"); + Files.writeString(path, manifest(use, status, markers, capability, stains)); + return path; + } + + private static String manifest(String use, String status, String markers, String capability, String stains) { + return "{\"schema\":\"pathlab.ai-pack/1\",\"packId\":\"ihc-descriptive-v1\",\"version\":\"1\"," + + "\"capability\":\"" + capability + "\",\"acceptedStains\":" + stains + "," + + "\"preprocessing\":{\"id\":\"od-v1\",\"tilePixels\":512},\"artifacts\":[]," + + "\"rights\":{\"license\":\"internal\",\"allowedUse\":\"" + use + "\"," + + "\"redistributable\":false,\"derivativesAllowed\":false,\"reviewedAt\":\"2026-08-22T00:00:00Z\"}," + + "\"resourceEnvelope\":{\"maxRamMiB\":4096,\"maxVramMiB\":4096,\"maxSeconds\":300,\"network\":false}," + + "\"validation\":{\"status\":\"" + status + "\",\"modelCard\":\"model.md\",\"heldOutEvaluation\":\"evaluation.json\"}," + + "\"outputSchema\":\"pathlab.ai-evidence/1\",\"markers\":" + markers + "}"; + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceQualificationCohortTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceQualificationCohortTest.java new file mode 100644 index 0000000..f3f5040 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceQualificationCohortTest.java @@ -0,0 +1,68 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +final class EvidenceQualificationCohortTest { + private static final Set GROUPS = Set.of( + "breast", "gi", "lung", "lymph-node", "benign-reactive"); + + @Test + void reportsMissingGroupsAndSplitLeakageAsNotEvaluable() { + var samples = List.of( + sample("reference", "breast", "patient-1", "slide-1", "source-a"), + sample("query", "breast", "patient-1", "slide-2", "source-a"), + sample("ood", "breast", "patient-3", "slide-3", "source-c")); + var cohort = cohort(samples); + + var assessment = cohort.assessReadiness(); + + assertEquals("not_evaluable", assessment.status()); + assertTrue(assessment.reasons().contains("INSUFFICIENT_QUERY_GI")); + assertTrue(assessment.reasons().contains("PATIENT_SPLIT_OVERLAP")); + assertTrue(assessment.reasons().contains("SOURCE_SPLIT_OVERLAP")); + } + + @Test + void becomesReadyOnlyWithEveryPreregisteredGroupAndNoLeakage() { + var samples = new ArrayList(); + var index = 0; + for (var group : GROUPS.stream().sorted().toList()) { + samples.add(sample("reference", group, "patient-r-" + index, + "slide-r-" + index, "source-r-" + index)); + samples.add(sample("query", group, "patient-q-" + index, + "slide-q-" + index, "source-q-" + index)); + index++; + } + samples.add(sample("ood", "breast", "patient-o", "slide-o", "source-o")); + + assertEquals("ready", cohort(samples).assessReadiness().status()); + } + + private static EvidenceQualificationCohort cohort(List samples) { + return new EvidenceQualificationCohort(Path.of("cohort.json"), "cohort-v1", + Instant.parse("2026-08-22T00:00:00Z"), "private-research-model-qualification", + new EvidenceQualificationCohort.AcceptanceCriteria( + GROUPS, 1, 1, 1, 0, 0, 0, "color-histogram-v1", 0.05, 0.03, 0.8), + samples); + } + + private static EvidenceQualificationCohort.Sample sample( + String split, String group, String patient, String slide, String sourceGroup) { + var source = "dataset-" + sourceGroup; + var provenance = new EvidenceSampleManifest(source, patient, slide, "a".repeat(64), + "reviewed research terms", "research-group", "private-research", 1, false); + var cache = new EvidenceTileCacheManifest(Path.of("tile-cache.json"), "b".repeat(64), + "a".repeat(64), "revision-1", 512, 512, Path.of("sample.json"), + "c".repeat(64), 512, "png", "rgb-srgb-uint8", provenance, List.of()); + return new EvidenceQualificationCohort.Sample(split + "-" + group, Path.of("tile-cache.json"), + "b".repeat(64), group, "research-group", split, sourceGroup, cache); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceQuotaManagerTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceQuotaManagerTest.java new file mode 100644 index 0000000..f355b7c --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceQuotaManagerTest.java @@ -0,0 +1,28 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceQuotaManagerTest { + @TempDir Path temporaryDirectory; + + @Test + void exposesFixedBucketsAndUntouchableReserve() throws Exception { + var snapshot = new EvidenceQuotaManager(temporaryDirectory).snapshot(); + assertEquals(5, snapshot.buckets().size()); + assertEquals(45L * 1024 * 1024 * 1024, snapshot.buckets().get("source").limitBytes()); + assertTrue(snapshot.buckets().get("reserve").untouchable()); + try (var reservation = new EvidenceQuotaManager(temporaryDirectory) + .reserve(EvidenceQuotaManager.Bucket.MODELS, "dinov2-download", 1024)) { + assertEquals(1024, reservation.bytes()); + assertEquals(1024, new EvidenceQuotaManager(temporaryDirectory).snapshot() + .buckets().get("models").reservedBytes()); + } + assertEquals(0, new EvidenceQuotaManager(temporaryDirectory).snapshot() + .buckets().get("models").reservedBytes()); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceRealCohortIntegrationTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceRealCohortIntegrationTest.java new file mode 100644 index 0000000..8c32754 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceRealCohortIntegrationTest.java @@ -0,0 +1,25 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +final class EvidenceRealCohortIntegrationTest { + @Test + void validatesExplicitPrivateCohortWithoutPromotingMissingCoverage() throws Exception { + var value = System.getenv("PATHLAB_EVIDENCE_COHORT"); + Assumptions.assumeTrue(value != null && !value.isBlank(), + "Set PATHLAB_EVIDENCE_COHORT to run the private real-data contract check"); + + var cohort = EvidenceQualificationCohort.load(Path.of(value)); + var assessment = cohort.assessReadiness(); + + assertEquals("not_evaluable", assessment.status()); + assertTrue(assessment.reasons().contains("INSUFFICIENT_QUERY_GI")); + assertTrue(assessment.reasons().contains("INSUFFICIENT_REFERENCE_BREAST")); + assertTrue(assessment.reasons().contains("INSUFFICIENT_OOD")); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceSampleManifestTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceSampleManifestTest.java new file mode 100644 index 0000000..057c9f7 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceSampleManifestTest.java @@ -0,0 +1,20 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceSampleManifestTest { + @TempDir Path temporaryDirectory; + @Test void recordsGrandfatheredReadOnlySourceWithCompleteProvenance() throws Exception { + var path = temporaryDirectory.resolve("sample.json"); + Files.writeString(path, "{\"schema\":\"pathlab.evidence-sample/1\"," + + "\"source\":\"BRACS\",\"patientGroup\":\"patient-001\",\"slideId\":\"slide-001\"," + + "\"sha256\":\"" + "a".repeat(64) + "\",\"license\":\"research terms reviewed\"," + + "\"taskLabel\":\"breast-benign\",\"permittedUse\":\"private-research\"," + + "\"bytes\":1234,\"grandfatheredReadOnly\":true}"); + assertTrue(EvidenceSampleManifest.load(path).grandfatheredReadOnly()); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceSetWriterTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceSetWriterTest.java new file mode 100644 index 0000000..946413a --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceSetWriterTest.java @@ -0,0 +1,49 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceSetWriterTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void fusesOnlyTrustedEvidenceFromOneExactSlideRevision() throws Exception { + var first = evidence("a", "revision-1", "cells"); + var second = evidence("a", "revision-1", "ihc"); + var output = temporaryDirectory.resolve("artifacts/set/evidence-set.json"); + var result = new EvidenceSetWriter(temporaryDirectory).write( + output, "set-1", java.util.List.of(first, second), Instant.EPOCH); + var manifest = JSON.readTree(output.toFile()); + assertEquals(EvidenceSetWriter.SCHEMA, manifest.path("schema").asText()); + assertEquals(2, manifest.path("bundles").size()); + assertEquals(result.manifestSha256(), manifest.path("manifestSha256").asText()); + assertFalse(manifest.path("fusion").path("serialSectionCellMatching").asBoolean(true)); + + var foreign = evidence("b", "revision-1", "foreign"); + assertThrows(IllegalArgumentException.class, () -> new EvidenceSetWriter(temporaryDirectory) + .write(output, "bad-set", java.util.List.of(first, foreign), Instant.EPOCH)); + } + + private Path evidence(String shaSeed, String revision, String pack) throws Exception { + var unsigned = JSON.createObjectNode(); + unsigned.put("schema", EvidenceBundleWriter.SCHEMA); + unsigned.put("bundleId", "bundle-" + pack); + unsigned.putObject("source").put("slideSha256", shaSeed.repeat(64)).put("revision", revision); + unsigned.putObject("pack").put("id", pack).put("capability", "cell-morphology"); + unsigned.put("status", "completed"); + unsigned.put("researchOnly", true); + unsigned.put("notDiagnostic", true); + unsigned.put("reviewRequired", true); + var path = temporaryDirectory.resolve("artifacts/" + pack + "/evidence.json"); + new EvidenceBundleWriter(temporaryDirectory.resolve("signing")).write(path, unsigned); + return path; + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/EvidenceTileCacheManifestTest.java b/src/test/java/org/pathlab/forge/evidence/EvidenceTileCacheManifestTest.java new file mode 100644 index 0000000..e54ee94 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/EvidenceTileCacheManifestTest.java @@ -0,0 +1,101 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class EvidenceTileCacheManifestTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void validatesEveryCoordinatePixelAndProvenanceChecksum() throws Exception { + var fixture = fixture(); + var cache = EvidenceTileCacheManifest.load(fixture.manifest(), sha256(fixture.manifest())); + + assertEquals(512, cache.tilePixels()); + assertEquals(1, cache.tiles().size()); + cache.requireSource(sha256(fixture.source()), "revision-1", 1024, 768, 512); + } + + @Test + void rejectsTamperedTileAndStaleRevision() throws Exception { + var tampered = fixture(); + Files.writeString(tampered.tile(), "tampered"); + assertThrows(IllegalArgumentException.class, () -> + EvidenceTileCacheManifest.load(tampered.manifest(), sha256(tampered.manifest()))); + + var stale = fixture(); + var loaded = EvidenceTileCacheManifest.load(stale.manifest(), sha256(stale.manifest())); + assertThrows(IllegalArgumentException.class, () -> + loaded.requireSource(sha256(stale.source()), "revision-2", 1024, 768, 512)); + } + + @Test + void rejectsPathTraversalEvenWhenOutsideTileChecksumMatches() throws Exception { + var fixture = fixture(); + var outside = temporaryDirectory.resolve("outside.png"); + Files.copy(fixture.tile(), outside); + var root = JSON.readTree(fixture.manifest().toFile()); + var tile = (com.fasterxml.jackson.databind.node.ObjectNode) root.path("tiles").get(0); + tile.put("path", "../outside.png"); + tile.put("sha256", sha256(outside)); + JSON.writeValue(fixture.manifest().toFile(), root); + + assertThrows(IllegalArgumentException.class, () -> + EvidenceTileCacheManifest.load(fixture.manifest(), sha256(fixture.manifest()))); + } + + private Fixture fixture() throws Exception { + var root = temporaryDirectory.resolve(java.util.UUID.randomUUID().toString()); + Files.createDirectories(root.resolve("tiles")); + var source = root.resolve("source.png"); + var tile = root.resolve("tiles/tile-1.png"); + writeImage(source, 1024, 768); + writeImage(tile, 512, 512); + var sample = root.resolve("sample.json"); + Files.writeString(sample, "{\"schema\":\"pathlab.evidence-sample/1\"," + + "\"source\":\"fixture\",\"patientGroup\":\"patient-1\"," + + "\"slideId\":\"slide-1\",\"sha256\":\"" + sha256(source) + "\"," + + "\"license\":\"private fixture\",\"taskLabel\":\"breast-benign\"," + + "\"permittedUse\":\"private-research\",\"bytes\":" + Files.size(source) + "," + + "\"grandfatheredReadOnly\":false}"); + var manifest = root.resolve("tile-cache.json"); + Files.writeString(manifest, "{\"schema\":\"pathlab.tile-cache/1\",\"source\":{" + + "\"sha256\":\"" + sha256(source) + "\",\"slideRevision\":\"revision-1\"," + + "\"width\":1024,\"height\":768,\"sampleManifest\":\"sample.json\"," + + "\"sampleManifestSha256\":\"" + sha256(sample) + "\"}," + + "\"tilePixels\":512,\"encoding\":\"png\"," + + "\"preprocessingInput\":\"rgb-srgb-uint8\",\"tiles\":[{" + + "\"id\":\"tile-1\",\"path\":\"tiles/tile-1.png\"," + + "\"sha256\":\"" + sha256(tile) + "\",\"x\":0,\"y\":0," + + "\"width\":512,\"height\":512}]}"); + return new Fixture(source, tile, manifest); + } + + private static void writeImage(Path path, int width, int height) throws Exception { + var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(new Color(174, 91, 145)); + graphics.fillRect(0, 0, width, height); + graphics.dispose(); + ImageIO.write(image, "png", path.toFile()); + } + + private static String sha256(Path path) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(Files.readAllBytes(path))); + } + + private record Fixture(Path source, Path tile, Path manifest) {} +} diff --git a/src/test/java/org/pathlab/forge/evidence/ExternalModelWorkerTest.java b/src/test/java/org/pathlab/forge/evidence/ExternalModelWorkerTest.java new file mode 100644 index 0000000..23cb99b --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/ExternalModelWorkerTest.java @@ -0,0 +1,183 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class ExternalModelWorkerTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test void rejectsReusableEmbeddingsAndInvalidRegions() throws Exception { + var pack = EvidencePackManifest.load(Path.of( + "src/main/resources/evidence-packs/he-dinov2-small-v1.json")); + var validPrefix = "{\"schema\":\"pathlab.model-worker-result/1\"," + + "\"status\":\"completed\",\"packManifestSha256\":\"" + pack.sha256() + "\","; + assertThrows(IllegalArgumentException.class, () -> ExternalModelWorker.validateResult( + JSON.readTree(validPrefix + "\"regions\":[],\"embeddings\":[1,2]}"), pack)); + assertThrows(IllegalArgumentException.class, () -> ExternalModelWorker.validateResult( + JSON.readTree(validPrefix + "\"regions\":[{\"id\":\"r1\",\"stage\":\"refined\"," + + "\"kind\":\"support\",\"x\":0,\"y\":0,\"width\":0,\"height\":1,\"score\":0.5}]}"), pack)); + } + + @Test void acceptsBoundedQualificationMetricsButRejectsExportedEmbeddings() throws Exception { + var pack = EvidencePackManifest.load(Path.of( + "src/main/resources/evidence-packs/he-dinov2-small-v1.json")); + var prefix = "{\"schema\":\"pathlab.model-worker-result/1\"," + + "\"status\":\"completed\",\"packManifestSha256\":\"" + pack.sha256() + "\"," + + "\"regions\":[],\"qualificationMetrics\":{" + + "\"schema\":\"pathlab.he-retrieval-metrics/1\",\"cohortManifestSha256\":\"" + + "a".repeat(64) + "\",\"sampleCount\":360,\"referenceCount\":180," + + "\"queryCount\":180,\"oodCount\":0,\"evaluationGroups\":[\"gi\"]," + + "\"baselineMacroRecallAt5\":0.5,\"modelMacroRecallAt5\":0.7," + + "\"macroRecallAt5Improvement\":0.2,\"baselineMacroNdcgAt10\":0.4," + + "\"modelMacroNdcgAt10\":0.6,\"macroNdcgAt10Improvement\":0.2," + + "\"exactRankingRepeatability\":true,\"notEvaluableReasons\":[\"INSUFFICIENT_OOD\"]," + + "\"embeddingsExported\":"; + + assertDoesNotThrow(() -> ExternalModelWorker.validateResult( + JSON.readTree(prefix + "false}}"), pack)); + assertThrows(IllegalArgumentException.class, () -> ExternalModelWorker.validateResult( + JSON.readTree(prefix + "true}}"), pack)); + } + + @Test void exposesOnlyTheControlledWorkerFailureLine() { + var output = "boot noise C:\\private\\slide.svs\r\n" + + "PathLab DINOv2 worker failed closed: CUDA sm_61 host is unavailable\r\n" + + "Traceback C:\\private\\slide.svs\r\n"; + + assertEquals("CUDA sm_61 host is unavailable", + ExternalModelWorker.safeFailureDetail(output)); + assertEquals("worker exceeded its declared resource envelope", + ExternalModelWorker.safeFailureDetail( + "PathLab model worker failed closed: worker exceeded its declared resource envelope")); + assertEquals("", ExternalModelWorker.safeFailureDetail( + "Traceback C:\\private\\patient-123.svs")); + } + + @Test void validatesPortableRuntimeFilesBeforeLaunchingPython(@TempDir Path install) throws Exception { + var runtime = install.resolve("runtime"); + Files.createDirectories(runtime); + var python = runtime.resolve("python.exe"); + Files.writeString(python, "portable-python"); + var hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(Files.readAllBytes(python))); + var manifest = JSON.readTree(""" + {"schema":"pathlab.model-runtime/1","files":[ + {"path":"runtime/python.exe","bytes":15,"sha256":"%s"} + ]} + """.formatted(hash)); + + assertDoesNotThrow(() -> ExternalModelWorker.validateRuntimeFileLedger(manifest, install)); + Files.writeString(python, "tampered"); + assertThrows(IllegalArgumentException.class, + () -> ExternalModelWorker.validateRuntimeFileLedger(manifest, install)); + var traversal = JSON.readTree(""" + {"schema":"pathlab.model-runtime/1","files":[ + {"path":"../outside","bytes":1,"sha256":"%s"} + ]} + """.formatted(hash)); + assertThrows(IllegalArgumentException.class, + () -> ExternalModelWorker.validateRuntimeFileLedger(traversal, install)); + } + + @Test void validatesSharedRuntimeAndCandidateReferencesBeforeLaunch(@TempDir Path state) throws Exception { + var models = state.resolve("models"); + var runtimeRoot = models.resolve("he-dinov2-small-v1/1"); + var python = runtimeRoot.resolve("runtime/Scripts/python.exe"); + Files.createDirectories(python.getParent()); + Files.writeString(python, "portable-python"); + var pythonHash = sha256(python); + var runtimeManifest = runtimeRoot.resolve("runtime-manifest.json"); + Files.writeString(runtimeManifest, """ + {"schema":"pathlab.model-runtime/1","files":[ + {"path":"runtime/Scripts/python.exe","bytes":15,"sha256":"%s"} + ]} + """.formatted(pythonHash)); + var candidateRoot = models.resolve("hovernet-fast-monusac-v1/1"); + Files.createDirectories(candidateRoot); + var ledger = candidateRoot.resolve("candidate-ledger.json"); + var weight = candidateRoot.resolve("weights.tar"); + Files.writeString(ledger, "frozen-candidate"); + Files.writeString(weight, "frozen-weight"); + var install = models.resolve("cell-hovernet-fast-monusac-v1/4"); + Files.createDirectories(install); + var reference = JSON.readTree(""" + {"schema":"pathlab.model-runtime-reference/1", + "sharedRuntimePack":"he-dinov2-small-v1","sharedRuntimeVersion":"1", + "sharedRuntimeRoot":"%s","sharedRuntimeManifestSha256":"%s", + "pythonRelativePath":"runtime/Scripts/python.exe", + "candidateId":"hovernet-fast-monusac-v1","candidateVersion":"1", + "candidateRoot":"%s","candidateLedgerSha256":"%s", + "weightFile":"weights.tar","weightSha256":"%s", + "runtimeCopiedIntoCandidate":false,"analysisNetwork":"disabled"} + """.formatted(runtimeRoot.toString().replace("\\", "\\\\"), sha256(runtimeManifest), + candidateRoot.toString().replace("\\", "\\\\"), sha256(ledger), sha256(weight))); + + var resolved = ExternalModelWorker.resolveSharedRuntime(state, install, reference); + assertEquals(python.toAbsolutePath().normalize(), resolved.python()); + assertEquals(runtimeRoot.toAbsolutePath().normalize(), resolved.root()); + Files.writeString(weight, "tampered"); + assertThrows(IllegalArgumentException.class, + () -> ExternalModelWorker.resolveSharedRuntime(state, install, reference)); + } + + @Test void acceptsBoundedCellQualificationMetrics() throws Exception { + var pack = EvidencePackManifest.load(Path.of( + "src/main/resources/evidence-packs/cell-hovernet-fast-v1.json")); + var result = JSON.readTree(""" + {"schema":"pathlab.model-worker-result/1","status":"completed", + "packManifestSha256":"%s","regions":[],"qualificationMetrics":{ + "schema":"pathlab.cell-instance-metrics/1","cohortManifestSha256":"%s", + "sampleCount":23,"evaluatedSampleCount":23,"macroPq":0.5,"instanceDice":0.75, + "countError":0.1,"morphometryBias":0.08,"failedRegionRate":0.0, + "deterministicRepeat":true,"crossTissuePerformance":true, + "rightsAndIntegrityPassed":true,"resourceCompliant":true, + "elapsedSeconds":200.0,"peakHeapMiB":1200.0,"minimumMacroPq":0.45, + "minimumInstanceDice":0.7,"maximumCountError":0.15, + "maximumMorphometryBias":0.1,"maximumFailedRegionRate":0.05, + "sourceIntegrity":"local-first-acquisition-sha256","upstreamChecksumAvailable":false, + "perOrgan":{},"sampleFailures":[],"notEvaluableReasons":[]}} + """.formatted(pack.sha256(), "a".repeat(64))); + + assertDoesNotThrow(() -> ExternalModelWorker.validateResult(result, pack)); + ((com.fasterxml.jackson.databind.node.ObjectNode) result.path("qualificationMetrics")) + .put("macroPq", Double.NaN); + assertThrows(IllegalArgumentException.class, () -> ExternalModelWorker.validateResult(result, pack)); + } + + @Test void deliversFinalProgressWrittenAsTheWorkerExits(@TempDir Path root) throws Exception { + var pack = EvidencePackManifest.load(Path.of( + "src/main/resources/evidence-packs/he-dinov2-small-v1.json")); + var checkpoint = root.resolve("checkpoint-000023.json"); + Files.writeString(checkpoint, "final-checkpoint"); + var progress = root.resolve("progress.json"); + Files.writeString(progress, """ + {"schema":"pathlab.model-worker-progress/1","jobId":"qualification-0123abcd", + "packManifestSha256":"%s","completedUnits":23,"totalUnits":23, + "checkpointPath":"%s","checkpointSha256":"%s", + "updatedAt":"2026-08-24T00:00:00Z"} + """.formatted(pack.sha256(), checkpoint.toString().replace("\\", "\\\\"), sha256(checkpoint))); + var delivered = new AtomicReference(); + + var result = ExternalModelWorker.notifyFinalProgress(progress, "qualification-0123abcd", pack, + new ExternalModelWorker.Progress(22, 23, null, ""), delivered::set); + + assertEquals(23, result.completedUnits()); + assertEquals(23, delivered.get().completedUnits()); + } + + private static String sha256(Path path) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(Files.readAllBytes(path))); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/IhcMeasurementAnalyzerTest.java b/src/test/java/org/pathlab/forge/evidence/IhcMeasurementAnalyzerTest.java new file mode 100644 index 0000000..f30f9cd --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/IhcMeasurementAnalyzerTest.java @@ -0,0 +1,53 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class IhcMeasurementAnalyzerTest { + @Test + void keepsNuclearAndMembraneMeasurementsDescriptive() { + var image = fixture(); + var nuclear = IhcMeasurementAnalyzer.analyze(image, "ki-67", List.of(), false); + var membrane = IhcMeasurementAnalyzer.analyze(image, "her2", List.of(), false); + assertEquals("nuclear", nuclear.compartment()); + assertTrue(nuclear.measurements().containsKey("dabAssociatedNuclei")); + assertEquals("membrane", membrane.compartment()); + assertTrue(membrane.measurements().containsKey("meanMembraneCompleteness")); + assertTrue(nuclear.measurements().keySet().stream().noneMatch(key -> Set.of("positive", "tps", "cps").contains(key))); + } + + @Test + void refusesPdL1CompartmentsUntilReviewedGeometryExists() { + var fallback = IhcMeasurementAnalyzer.analyze(fixture(), "pd-l1", List.of(), false); + assertEquals("generic-fallback", fallback.analysisMode()); + assertEquals("COMPARTMENT_REVIEW_REQUIRED", fallback.abstentionReason()); + + var reviewed = IhcMeasurementAnalyzer.analyze(fixture(), "pd-l1", List.of( + new IhcMeasurementAnalyzer.ReviewedRegion( + "tumor-1", "tumor", "faculty-approved", 0, 0, 16, 16)), false); + assertEquals("marker-aware", reviewed.analysisMode()); + assertNull(reviewed.abstentionReason()); + assertTrue(reviewed.measurements().containsKey("compartments")); + } + + private static BufferedImage fixture() { + var image = new BufferedImage(32, 24, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, 32, 24); + graphics.setColor(new Color(75, 45, 125)); graphics.fillRect(5, 5, 6, 6); + graphics.setColor(new Color(135, 90, 45)); graphics.fillRect(4, 4, 8, 1); + graphics.fillRect(4, 11, 8, 1); graphics.fillRect(4, 4, 1, 8); graphics.fillRect(11, 4, 1, 8); + graphics.dispose(); + return image; + } + + private static final class Set { + static java.util.Set of(String... values) { return java.util.Set.of(values); } + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/QualificationCampaignManifestTest.java b/src/test/java/org/pathlab/forge/evidence/QualificationCampaignManifestTest.java new file mode 100644 index 0000000..27d2aa3 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/QualificationCampaignManifestTest.java @@ -0,0 +1,44 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class QualificationCampaignManifestTest { + @TempDir Path temporaryDirectory; + + @Test + void loadsFrozenAllRounderCampaignWithoutAllowingPathEscape() throws Exception { + Files.writeString(temporaryDirectory.resolve("request.json"), "{}"); + var manifest = temporaryDirectory.resolve("campaign.json"); + Files.writeString(manifest, campaign("request.json")); + + var loaded = QualificationCampaignManifest.load(manifest); + assertEquals("all-rounder-1", loaded.campaignId()); + assertEquals(1, loaded.maxRemediationAttempts()); + assertEquals("he-evidence", loaded.tracks().get(0).capability()); + + Files.writeString(manifest, campaign("../request.json")); + assertThrows(IllegalArgumentException.class, + () -> QualificationCampaignManifest.load(manifest)); + } + + static String campaign(String requestPath) { + return """ + {"schema":"pathlab.qualification-campaign/1","campaignId":"all-rounder-1", + "createdAt":"2026-08-22T00:00:00Z","researchOnly":true,"notDiagnostic":true, + "maxRemediationAttempts":1, + "quota":{"sourceBytes":48318382080,"derivedBytes":26843545600, + "modelBytes":10737418240,"evidenceBytes":10737418240,"reserveBytes":10737418240}, + "tracks":[{"id":"dinov2","candidateId":"dinov2-small","capability":"he-evidence", + "scope":"deployment","requestPath":"%s","remediationRequestPath":null, + "expectedAttestationPath":null, + "protocolSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "dependsOn":[],"required":true}]} + """.formatted(requestPath.replace("\\", "\\\\")); + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/QualificationReportValidatorTest.java b/src/test/java/org/pathlab/forge/evidence/QualificationReportValidatorTest.java new file mode 100644 index 0000000..e4ebfac --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/QualificationReportValidatorTest.java @@ -0,0 +1,58 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class QualificationReportValidatorTest { + private static final ObjectMapper JSON = new ObjectMapper(); + @TempDir Path temporaryDirectory; + + @Test + void trustsPinnedSignerInsteadOfPayloadSuppliedKey() throws Exception { + Files.writeString(temporaryDirectory.resolve("request.json"), "{}"); + var campaignPath = temporaryDirectory.resolve("campaign.json"); + Files.writeString(campaignPath, QualificationCampaignManifestTest.campaign("request.json")); + var campaign = QualificationCampaignManifest.load(campaignPath); + var unsigned = report(campaign); + var reportPath = temporaryDirectory.resolve("report.json"); + new QualificationReportWriter(temporaryDirectory.resolve("state")).write(reportPath, unsigned); + + var result = new QualificationReportValidator(temporaryDirectory.resolve("state")) + .validate(reportPath, campaign, campaign.tracks().get(0)); + assertEquals("qualified", result.verdict()); + + Files.delete(temporaryDirectory.resolve("state/trust/approved-signers.json")); + assertThrows(IllegalArgumentException.class, () -> + new QualificationReportValidator(temporaryDirectory.resolve("state")) + .validate(reportPath, campaign, campaign.tracks().get(0))); + } + + static ObjectNode report(QualificationCampaignManifest campaign) { + var track = campaign.tracks().get(0); + var root = JSON.createObjectNode(); + root.put("schema", "pathlab.model-qualification-report/2"); + root.put("campaignId", campaign.campaignId()); + root.put("campaignManifestSha256", campaign.sha256()); + root.put("trackId", track.id()); + root.put("candidateId", track.candidateId()); + root.put("capability", track.capability()); + root.put("scope", track.scope()); + root.put("packManifestSha256", "b".repeat(64)); + root.put("protocolSha256", track.protocolSha256()); + root.put("generatedAt", "2026-08-22T00:00:00Z"); + root.put("researchOnly", true); + root.put("notDiagnostic", true); + root.put("status", "qualified"); + var checks = root.putArray("checks"); + checks.addObject().put("id", "determinism").put("outcome", "pass"); + root.putArray("reasons"); + return root; + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/QualificationRunStoreTest.java b/src/test/java/org/pathlab/forge/evidence/QualificationRunStoreTest.java new file mode 100644 index 0000000..c4afc2a --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/QualificationRunStoreTest.java @@ -0,0 +1,90 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class QualificationRunStoreTest { + @TempDir Path temporaryDirectory; + + @Test + void persistsSignedAttestationAndComputesTargetWithoutChangingCandidateManifest() throws Exception { + var packPath = temporaryDirectory.resolve("pack.json"); + Files.writeString(packPath, deploymentPack()); + Files.writeString(temporaryDirectory.resolve("request.json"), + "{\"packManifest\":" + new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString(packPath.toAbsolutePath().toString()) + "}"); + var campaignPath = temporaryDirectory.resolve("campaign.json"); + Files.writeString(campaignPath, QualificationCampaignManifestTest.campaign("request.json")); + var campaign = QualificationCampaignManifest.load(campaignPath); + var state = Files.createDirectories(temporaryDirectory.resolve("state")); + try (var store = new QualificationRunStore(state.resolve("jobs.sqlite3"), state)) { + var created = store.create(campaign, Instant.parse("2026-08-22T00:00:00Z")); + assertFalse(created.campaignCompleted()); + var report = temporaryDirectory.resolve("report.json"); + var unsigned = QualificationReportValidatorTest.report(campaign); + unsigned.put("packManifestSha256", EvidencePackManifest.load(packPath).sha256()); + new QualificationReportWriter(state).write(report, unsigned); + var completed = store.attachAttestation(campaign.campaignId(), "dinov2", report, + Instant.parse("2026-08-22T00:01:00Z")); + assertTrue(completed.campaignCompleted()); + assertTrue(completed.campaignTargetMet()); + assertEquals("qualified", completed.tracks().get(0).verdict()); + assertEquals(campaign.sha256(), completed.manifestSha256()); + new QualifiedPackRegistry(state).requireQualified(EvidencePackManifest.load(packPath), + completed.tracks().get(0).artifactSha256()); + } + } + + @Test + void unavailableCandidateFinishesNotEvaluableWithoutBlockingCampaignTick() throws Exception { + var missingPack = temporaryDirectory.resolve("missing-pack.json").toAbsolutePath(); + Files.writeString(temporaryDirectory.resolve("request.json"), + "{\"packManifest\":" + new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString(missingPack.toString()) + "}"); + var campaignPath = temporaryDirectory.resolve("campaign.json"); + Files.writeString(campaignPath, QualificationCampaignManifestTest.campaign("request.json")); + var campaign = QualificationCampaignManifest.load(campaignPath); + var state = Files.createDirectories(temporaryDirectory.resolve("state")); + try (var store = new QualificationRunStore(state.resolve("jobs.sqlite3"), state); + var queue = new EvidenceJobQueue(state.resolve("jobs.sqlite3"))) { + store.create(campaign, Instant.parse("2026-08-22T00:00:00Z")); + store.tick(queue, Instant.parse("2026-08-22T00:00:01Z")); + + var completed = store.snapshot(campaign.campaignId()).orElseThrow(); + assertTrue(completed.campaignCompleted()); + assertFalse(completed.campaignTargetMet()); + assertEquals("not_evaluable", completed.tracks().get(0).verdict()); + assertEquals("CANDIDATE_PREFLIGHT_FAILED", completed.tracks().get(0).failureCode()); + assertTrue(Files.isRegularFile(state.resolve("qualification") + .resolve(campaign.campaignId()).resolve("capability-matrix.json"))); + } + } + + private static String deploymentPack() { + return """ + {"schema":"pathlab.ai-pack/2","packId":"dinov2-small","version":"1.0.0", + "capability":"he-evidence","scope":"deployment","activationEligible":true, + "acceptedStains":["he"],"preprocessing":{"id":"frozen","tilePixels":512}, + "artifacts":[],"runtimeCompatibility":{"workerProtocol":"pathlab.model-worker-result/2", + "executionProvider":"cpu","cuda":"none","gpuArchitecture":"none", + "requiresExternalWorker":false,"segmentMaxSeconds":120}, + "licenseLedger":[{"component":"test","kind":"weights","license":"test-only", + "revision":"fixed","permittedUse":"private-research","redistributable":false, + "derivativesAllowed":true}], + "derivativePermissions":{"atlasResearch":true,"atlasClean":true, + "reviewedAt":"2026-08-22T00:00:00Z"}, + "resourceEnvelope":{"lane":"cpu_io","maxRamMiB":1024,"maxVramMiB":0, + "maxSeconds":120,"network":false}, + "qualificationPolicy":{"requiresSignedAttestation":true, + "protocolSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "outputSchema":"pathlab.model-qualification-report/2","markers":[]} + """; + } +} diff --git a/src/test/java/org/pathlab/forge/evidence/SpecialStainAnalyzerTest.java b/src/test/java/org/pathlab/forge/evidence/SpecialStainAnalyzerTest.java new file mode 100644 index 0000000..d7102e9 --- /dev/null +++ b/src/test/java/org/pathlab/forge/evidence/SpecialStainAnalyzerTest.java @@ -0,0 +1,28 @@ +package org.pathlab.forge.evidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +final class SpecialStainAnalyzerTest { + @Test + void emitsDescriptivePasAndPapanicolaouMeasurementsWithUnknownFallback() { + var image = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, 20, 20); + graphics.setColor(new Color(180, 70, 150)); graphics.fillRect(2, 2, 10, 10); + graphics.dispose(); + + var pas = SpecialStainAnalyzer.analyze(image, "pas"); + assertEquals("special-stain-descriptive", pas.analysisMode()); + assertTrue((double) pas.measurements().get("stainAreaFraction") > 0); + var pap = SpecialStainAnalyzer.analyze(image, "papanicolaou"); + assertEquals("cytology-descriptive", pap.analysisMode()); + var unknown = SpecialStainAnalyzer.analyze(image, "reticulin"); + assertEquals("generic_brightfield", unknown.stainId()); + assertEquals("UNSUPPORTED_STAIN_GENERIC_FALLBACK", unknown.abstentionReason()); + } +} diff --git a/src/test/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilderTest.java b/src/test/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilderTest.java index 32a2452..e9de593 100644 --- a/src/test/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilderTest.java +++ b/src/test/java/org/pathlab/forge/viewer/PrivateResultsBundleBuilderTest.java @@ -42,6 +42,20 @@ void createsCanonicalBoundedResultSidecarFromAvailableAnnotations() throws Excep assertFalse(entries.keySet().stream().anyMatch(name -> name.contains(":\\") || name.startsWith("/"))); } + @Test + void includesSignedEvidenceWithoutChangingDeliverySchema() throws Exception { + var evidence = temporaryDirectory.resolve("evidence.json"); + Files.writeString(evidence, "{\"schema\":\"pathlab.ai-evidence/1\"}"); + var output = temporaryDirectory.resolve("evidence-results.plresults"); + + new PrivateResultsBundleBuilder().build( + output, "revision-1", "a".repeat(64), List.of(), evidence); + + var entries = readTarGz(output); + assertEquals("pathlab.ai-evidence/1", text(entries, "evidence.json", "schema")); + assertEquals("pathlab-private-results/v1", text(entries, "manifest.json", "schema")); + } + private static String text(HashMap entries, String name, String key) throws Exception { return new com.fasterxml.jackson.databind.ObjectMapper() .readTree(entries.get(name)).get(key).asText();