diff --git a/README.md b/README.md index 4277ba6..b2ddda2 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,11 @@ Preview it locally from the repository root with `python -m http.server 8000`, t ## Professional portal demonstration -A separate local-only professional workflow portal is available for synthetic demonstrations. It -supports project creation, canonical Phase 5 feature preparation on mathematical terrain, screening -hypotheses, human review, evidence and audit records, and limitations-first reports. It does **not** -load the private model, accept real terrain, or provide live commercial inference. +A separate local-only professional workflow portal supports project creation, canonical Phase 5 +feature preparation on mathematical terrain, screening outputs, human review, evidence and audit +records, and limitations-first reports. The default uses a deterministic demonstration scorer. +Phase 6D also provides an explicit localhost-only mode that executes the hash-verified frozen E001 +Random Forest on the same synthetic terrain. Neither mode accepts real terrain or coordinates. ```powershell python -m pip install -e ".[portal]" @@ -58,7 +59,9 @@ archaeoai portal --demo --reset Open `http://127.0.0.1:8000`. Read the [Phase 6C implementation note](docs/product/PHASE_6C_COMMERCIAL_MVP.md) and -[portal runbook](docs/product/PORTAL_RUNBOOK.md) for boundaries and operator steps. +[portal runbook](docs/product/PORTAL_RUNBOOK.md) for boundaries and operator steps. The narrowly +approved real-model path is documented in the +[Phase 6D runtime note](docs/product/PHASE_6D_APPROVED_MODEL_RUNTIME.md). ## What is ArchaeoAI? diff --git a/docs/product/PHASE_6D_APPROVED_MODEL_RUNTIME.md b/docs/product/PHASE_6D_APPROVED_MODEL_RUNTIME.md new file mode 100644 index 0000000..a04a415 --- /dev/null +++ b/docs/product/PHASE_6D_APPROVED_MODEL_RUNTIME.md @@ -0,0 +1,73 @@ +# Phase 6D approved private model runtime + +Phase 6D connects the frozen E001 Random Forest to the local portal while retaining a deliberately +narrow input boundary: the real model can score only deterministic mathematical terrain generated +inside the application. Real terrain, uploads, coordinates, remote inference, training, tuning, and +archaeological determination remain out of scope. + +## Approved identity + +- Model: `E001_FROZEN_RANDOM_FOREST` (`sklearn.ensemble.RandomForestClassifier`) +- Configuration SHA-256: `20cd377c17373eeeb5403c84119084287f193d93b42c8004d99c823e01a157e4` +- Private artifact SHA-256: `50f7968069ecaa1e0016f37be6356531ab3f26802c806efb5dc8fb2e295a503f` +- Learned-state SHA-256: `e3b0c072f437e889f09a2a2cf5a37f19b2f483eb5188e102b132a89ee76d1939` +- Private ignored location: `data/private/e001/inference/e001_phase2f_random_forest.pkl` + +The path is fixed in the inference system. It is not accepted from the CLI, browser, or API. + +## Runtime architecture + +The owner explicitly authorizes the runtime at server startup. The loader resolves the repository +root and exact private location, confirms private containment and Git-ignore protection, verifies the +artifact digest before pickle deserialization, then validates the exact estimator class, learned +state, and frozen configuration identity. Any mismatch stops startup. It never searches for another +model, rebuilds one, or falls back to the demonstration scorer. + +For each approved run, the portal creates a deterministic 128 × 128 mathematical elevation surface, +uses the canonical four-representation Phase 5 transform and immutable 4,096-value `float32` model +input, and invokes the frozen Random Forest probability-scoring path. Features are discarded rather +than stored. Results record `AI_OUTPUT`, `APPROVED_PRIVATE_RANDOM_FOREST`, and +`PERFORMED_APPROVED_PRIVATE_MODEL` before entering the human-review workflow. + +Pickle can execute code during deserialization. This implementation therefore permits only the +single private, hash-bound artifact. Possession of repository or browser access does not authorize a +different pickle. + +## Safe interpretation + +The output is a **bounded terrain-pattern similarity score from the frozen E001 model**. It is not an +archaeological probability, archaeological confidence, discovery probability, or determination of +presence or absence. Synthetic input and required human review remain visible in the UI, API-safe +provenance, audit trail, and report. + +## Launch + +Default synthetic demonstration, including Web Preview: + +```powershell +archaeoai portal --demo --reset --host 0.0.0.0 +``` + +Real frozen model on synthetic terrain, local/private only: + +```powershell +archaeoai portal --demo --approved-model-runtime --reset +``` + +Open the latter at `http://127.0.0.1:8000`. Approved mode rejects `--host 0.0.0.0`; the default remains +`127.0.0.1`. + +## Expected closed failures + +Startup stops with a fixed safe message if the private artifact is missing, outside the approved +private boundary, not ignored, changed, not the exact classifier type, or different in learned state. +An API request cannot authorize a runtime that the server did not authorize at startup. Runtime and +request errors do not reveal filesystem paths, pickle details, features, or model internals. + +## Still unauthorized + +This phase does not complete Phase 5E independent review, authorize Phase 5F, establish pilot +readiness, or validate a commercial product. Real/customer terrain, file upload, public or remote +model execution, candidate-location publication, and archaeological discovery claims remain +prohibited. A later owner-approved gate must resolve external review findings before any broader +interface or real-data work. diff --git a/docs/product/PORTAL_RUNBOOK.md b/docs/product/PORTAL_RUNBOOK.md index a7fb632..79148bd 100644 --- a/docs/product/PORTAL_RUNBOOK.md +++ b/docs/product/PORTAL_RUNBOOK.md @@ -1,6 +1,7 @@ # ArchaeoAI portal runbook -This runbook operates the Phase 6C local synthetic demonstration. It does not enable live inference. +This runbook operates the local portal in either its default synthetic-scorer mode or its explicit +Phase 6D approved-model mode. Both modes accept only generated mathematical terrain. ## 1. Install @@ -45,11 +46,27 @@ Open: `http://127.0.0.1:8000` -Enter the demo workspace, then create, authorize, and run a synthetic project. The approved model -status must remain `DISABLED / NOT AUTHORIZED`, and each synthetic result must report +Enter the demo workspace, then create, authorize, and run a synthetic project. In default mode the +approved model status remains `DISABLED / NOT AUTHORIZED`, and each result reports `MODEL_EXECUTION: NOT_PERFORMED`. -## 5. Exercise the workflow +## 5. Optional approved private runtime + +Confirm the exact frozen artifact already exists at its approved ignored location, then launch: + +```powershell +archaeoai portal --demo --approved-model-runtime --reset +``` + +This mode binds only to `127.0.0.1`. It verifies the hash-bound private model before deserialization, +then permits the frozen model to score synthetic terrain. The UI should show +`REAL FROZEN MODEL · SYNTHETIC TERRAIN · LOCAL PRIVATE`; approved results report `AI_OUTPUT` and +`PERFORMED_APPROVED_PRIVATE_MODEL`. Never interpret the score as archaeological probability. + +Do not combine `--approved-model-runtime` with `--host 0.0.0.0`. Missing or invalid artifacts stop +startup without retraining, downloading, searching for alternatives, or using a fallback score. + +## 6. Exercise the workflow Use Projects to create or open a project, Results to filter synthetic hypotheses, Review to record a human observation, Report to generate a print-ready summary, Audit to inspect events, and Settings to @@ -57,11 +74,11 @@ change retention or delete the local project. No screen should request real terrain, coordinates, model files, or archaeological identifiers. -## 6. Stop the service +## 7. Stop the service Return to the terminal and press `Ctrl+C`. -## 7. Locate or delete local demo state +## 8. Locate or delete local demo state The database is: @@ -75,5 +92,6 @@ that SQLite file to remove all retained demo state. Running again with `--reset` - If the portal extra is missing, run `python -m pip install -e ".[portal]"`. - If the port is occupied, use `archaeoai portal --demo --port 8001` and open the reported address. - Ports below 1024 and non-local bind addresses are not supported. -- If approved-model mode is requested, the expected Phase 6C result is the controlled error - `APPROVED_MODEL_RUNTIME_NOT_AUTHORIZED`. +- If approved-model startup fails, verify only the owner-managed approved private artifact. Browser + requests cannot enable the model, and the portal intentionally exposes no model-path setting. +- Approved-model mode is localhost-only. Use the synthetic scorer for Web Preview. diff --git a/src/archaeoai/cli.py b/src/archaeoai/cli.py index ee7cce3..ced961e 100644 --- a/src/archaeoai/cli.py +++ b/src/archaeoai/cli.py @@ -394,6 +394,11 @@ def build_parser() -> SafeArgumentParser: help="launch the local synthetic professional portal demonstration", ) portal.add_argument("--demo", action="store_true", help="use synthetic demonstration mode") + portal.add_argument( + "--approved-model-runtime", + action="store_true", + help="owner-authorize the frozen private model for synthetic terrain on localhost only", + ) portal.add_argument("--reset", action="store_true", help="reset and seed local demo data") portal.add_argument("--port", type=int, default=8000, help="localhost port (default: 8000)") portal.add_argument( @@ -416,6 +421,7 @@ def main(argv: list[str] | None = None) -> int: reset=args.reset, port=args.port, host=args.host, + approved_model_runtime=args.approved_model_runtime, ) except Exception: print("ERROR: the local portal failed safely.", file=sys.stderr) diff --git a/src/archaeoai/inference_system/__init__.py b/src/archaeoai/inference_system/__init__.py index 32e975e..9f7c8b6 100644 --- a/src/archaeoai/inference_system/__init__.py +++ b/src/archaeoai/inference_system/__init__.py @@ -40,6 +40,13 @@ GeoTIFFValidationError, load_canonical_geotiff, ) +from archaeoai.inference_system.private_model_adapter import ( + FROZEN_MODEL_STATE_SHA256, + ApprovedPrivateModelLoadError, + ApprovedPrivateRandomForestAdapter, + approved_private_model_reference, + load_approved_private_random_forest, +) from archaeoai.inference_system.single_patch import ( APPROVED_MODEL_ARTIFACT_SHA256, APPROVED_MODEL_RELATIVE_PATH, @@ -62,6 +69,8 @@ "APPROVED_MODEL_ARTIFACT_SHA256", "APPROVED_MODEL_RELATIVE_PATH", "ApprovedModelArtifactReference", + "ApprovedPrivateModelLoadError", + "ApprovedPrivateRandomForestAdapter", "AutomaticInferenceResult", "BATCH_MANIFEST_SCHEMA_VERSION", "BATCH_RESULT_SCHEMA_VERSION", @@ -74,6 +83,7 @@ "BatchRunResult", "CanonicalGeoTIFF", "EvidenceLevel", + "FROZEN_MODEL_STATE_SHA256", "GeoTIFFErrorCode", "GeoTIFFValidationError", "LimitationCode", @@ -96,10 +106,12 @@ "WarningCode", "verify_model_artifact_checksum", "load_batch_manifest", + "load_approved_private_random_forest", "load_canonical_geotiff", "run_feature_batch", "run_approved_single_patch_inference", "score_single_patch_model_adapter", "transform_single_patch", "verify_approved_model_artifact", + "approved_private_model_reference", ] diff --git a/src/archaeoai/inference_system/private_model_adapter.py b/src/archaeoai/inference_system/private_model_adapter.py new file mode 100644 index 0000000..8636987 --- /dev/null +++ b/src/archaeoai/inference_system/private_model_adapter.py @@ -0,0 +1,113 @@ +"""Hash-bound loader and adapter for the one approved private E001 model.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np +from sklearn.ensemble import RandomForestClassifier + +from archaeoai.inference import FEATURE_COUNT, load_private_model, model_state_sha256 +from archaeoai.inference import score_feature_matrix as score_frozen_feature_matrix +from archaeoai.inference_system.contracts import ( + APPROVED_MODEL_CONFIG_SHA256, + ModelIdentifier, +) +from archaeoai.inference_system.single_patch import ( + APPROVED_MODEL_ARTIFACT_SHA256, + APPROVED_MODEL_RELATIVE_PATH, + ApprovedModelArtifactReference, + verify_approved_model_artifact, +) +from archaeoai.terrain.privacy import ensure_private_output, verify_git_ignored + +FROZEN_MODEL_STATE_SHA256 = "e3b0c072f437e889f09a2a2cf5a37f19b2f483eb5188e102b132a89ee76d1939" + + +class ApprovedPrivateModelLoadError(RuntimeError): + """Safe public failure for an unavailable or invalid approved model runtime.""" + + code = "APPROVED_PRIVATE_MODEL_UNAVAILABLE" + + +@dataclass(frozen=True, slots=True) +class ApprovedPrivateRandomForestAdapter: + """Expose only frozen single-patch scoring from a verified private estimator.""" + + _estimator: RandomForestClassifier = field(repr=False) + model_identifier: ModelIdentifier = ModelIdentifier.E001_FROZEN_RANDOM_FOREST + model_config_sha256: str = APPROVED_MODEL_CONFIG_SHA256[ + ModelIdentifier.E001_FROZEN_RANDOM_FOREST + ] + model_artifact_sha256: str = APPROVED_MODEL_ARTIFACT_SHA256 + + def __post_init__(self) -> None: + if self.model_identifier is not ModelIdentifier.E001_FROZEN_RANDOM_FOREST: + raise ValueError("approved private model identifier mismatch") + if self.model_config_sha256 != APPROVED_MODEL_CONFIG_SHA256[self.model_identifier]: + raise ValueError("approved private model configuration identity mismatch") + if self.model_artifact_sha256 != APPROVED_MODEL_ARTIFACT_SHA256: + raise ValueError("approved private model artifact identity mismatch") + if type(self._estimator) is not RandomForestClassifier: + raise TypeError("approved adapter requires the exact RandomForestClassifier class") + if model_state_sha256(self._estimator) != FROZEN_MODEL_STATE_SHA256: + raise ValueError("approved private model state mismatch") + + def score_model_input(self, feature_matrix: np.ndarray) -> float: + """Score one immutable frozen feature row through the canonical implementation.""" + if ( + not isinstance(feature_matrix, np.ndarray) + or feature_matrix.shape != (1, FEATURE_COUNT) + or feature_matrix.dtype != np.float32 + or feature_matrix.flags.writeable + or not feature_matrix.flags.c_contiguous + or not np.isfinite(feature_matrix).all() + ): + raise ValueError("approved model input contract mismatch") + scores = score_frozen_feature_matrix(self._estimator, feature_matrix) + if scores.shape != (1,) or not math.isfinite(float(scores[0])) or not 0 <= scores[0] <= 1: + raise ValueError("approved model output contract mismatch") + return float(scores[0]) + + +def approved_private_model_reference(project_root: Path) -> ApprovedModelArtifactReference: + """Build the only authorized reference without accepting a caller-supplied path.""" + root = Path(project_root).resolve() + return ApprovedModelArtifactReference( + path=root / APPROVED_MODEL_RELATIVE_PATH, + model_identifier=ModelIdentifier.E001_FROZEN_RANDOM_FOREST, + model_config_sha256=APPROVED_MODEL_CONFIG_SHA256[ModelIdentifier.E001_FROZEN_RANDOM_FOREST], + ) + + +def load_approved_private_random_forest( + project_root: Path, +) -> ApprovedPrivateRandomForestAdapter: + """Verify every approved identity before allowing private pickle deserialization.""" + root = Path(project_root).resolve() + reference = approved_private_model_reference(root) + try: + private_path = ensure_private_output(root, reference.path) + if private_path != (root / APPROVED_MODEL_RELATIVE_PATH).resolve(): + raise ValueError("unexpected approved model path") + verify_git_ignored(root, private_path) + verified_artifact_sha256 = verify_approved_model_artifact(root, reference) + if verified_artifact_sha256 != APPROVED_MODEL_ARTIFACT_SHA256: + raise ValueError("approved model artifact identity mismatch") + estimator = load_private_model( + root, + private_path, + expected_artifact_sha256=APPROVED_MODEL_ARTIFACT_SHA256, + expected_state_sha256=FROZEN_MODEL_STATE_SHA256, + ) + if type(estimator) is not RandomForestClassifier: + raise ValueError("approved model class mismatch") + if model_state_sha256(estimator) != FROZEN_MODEL_STATE_SHA256: + raise ValueError("approved model state mismatch") + return ApprovedPrivateRandomForestAdapter(estimator) + except ApprovedPrivateModelLoadError: + raise + except Exception as exc: + raise ApprovedPrivateModelLoadError(ApprovedPrivateModelLoadError.code) from exc diff --git a/src/archaeoai/portal/app.py b/src/archaeoai/portal/app.py index 4a65837..b0c8023 100644 --- a/src/archaeoai/portal/app.py +++ b/src/archaeoai/portal/app.py @@ -11,7 +11,11 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.trustedhost import TrustedHostMiddleware -from archaeoai.portal.model_runtime import ApprovedModelRuntimeNotAuthorizedError +from archaeoai.portal.model_runtime import ( + ApprovedModelRuntimeExecutionError, + ApprovedModelRuntimeNotAuthorizedError, + ScreeningRuntime, +) from archaeoai.portal.repository import ( DEMO_ORGANIZATION_ID, DEMO_USER_ID, @@ -58,10 +62,14 @@ def _safe_project(repository: PortalRepository, project_id: str) -> dict: raise PortalApiError("PROJECT_NOT_FOUND", 404) from exc -def create_app(database_path: Path | None = None) -> FastAPI: +def create_app( + database_path: Path | None = None, + *, + approved_runtime: ScreeningRuntime | None = None, +) -> FastAPI: """Create an offline portal bound to one local demo organization.""" repository = PortalRepository(database_path or _default_database_path()) - workflow = PortalWorkflow(repository) + workflow = PortalWorkflow(repository, approved_runtime=approved_runtime) static_root = Path(__file__).with_name("static") app = FastAPI( title="ArchaeoAI professional portal demonstration", @@ -132,6 +140,24 @@ async def portal_error(_request: Request, exc: PortalApiError) -> JSONResponse: status_code=exc.status_code, ) + @app.exception_handler(ApprovedModelRuntimeNotAuthorizedError) + async def approved_not_authorized( + _request: Request, exc: ApprovedModelRuntimeNotAuthorizedError + ) -> JSONResponse: + return JSONResponse( + {"error": exc.code, "message": "Approved runtime is not server-authorized."}, + status_code=403, + ) + + @app.exception_handler(ApprovedModelRuntimeExecutionError) + async def approved_execution_failed( + _request: Request, exc: ApprovedModelRuntimeExecutionError + ) -> JSONResponse: + return JSONResponse( + {"error": exc.code, "message": "Approved runtime failed safely."}, + status_code=503, + ) + @app.exception_handler(ValueError) async def state_error(_request: Request, exc: ValueError) -> JSONResponse: allowed = { @@ -147,17 +173,24 @@ async def state_error(_request: Request, exc: ValueError) -> JSONResponse: ) @app.get("/api/v1/health") - def health() -> dict[str, str]: + def health() -> dict[str, object]: + runtime_status = workflow.runtime_status() return { "status": "READY", "portal": "LOCAL_DEMO", "demo_runtime": "AVAILABLE", - "approved_model_runtime": "DISABLED_NOT_AUTHORIZED", + "approved_model_runtime": ( + "VERIFIED_LOCAL_SYNTHETIC_ONLY" + if runtime_status["model_execution_available"] + else "DISABLED_NOT_AUTHORIZED" + ), "storage": "LOCAL_PRIVATE_SQLITE", + "runtime_status": runtime_status, } @app.get("/api/v1/session") def session() -> dict[str, object]: + runtime_status = workflow.runtime_status() return { "session_type": "LOCAL_DEMONSTRATION_ONLY", "authenticated": False, @@ -166,7 +199,8 @@ def session() -> dict[str, object]: "id": DEMO_ORGANIZATION_ID, "name": "ArchaeoAI Demonstration Workspace", }, - "model_runtime": "DISABLED_NOT_AUTHORIZED", + "model_runtime": runtime_status["runtime_mode"], + "runtime_status": runtime_status, } @app.get("/api/v1/overview") @@ -180,7 +214,11 @@ def overview() -> dict[str, object]: ), "reports_ready": sum(p["status"] == "REPORT_READY" for p in projects), "recent_projects": projects[:5], - "model_execution": "NOT_PERFORMED", + "model_execution": ( + "AVAILABLE_ON_SYNTHETIC_INPUT" + if workflow.runtime_status()["model_execution_available"] + else "NOT_PERFORMED" + ), } @app.get("/api/v1/projects") @@ -274,11 +312,12 @@ def update_retention(project_id: str, payload: RetentionUpdate) -> dict: return repository.update_retention(project["id"], payload.retention_policy.value) @app.post("/api/v1/runtime/approved/check") - def approved_runtime_check(_payload: ApprovedRuntimeRequest) -> None: + def approved_runtime_check(_payload: ApprovedRuntimeRequest) -> dict[str, object]: try: workflow.approved_runtime.validate() except ApprovedModelRuntimeNotAuthorizedError as exc: raise PortalApiError(exc.code, 403) from exc + return workflow.runtime_status() app.mount("/static", StaticFiles(directory=static_root), name="static") diff --git a/src/archaeoai/portal/approved_runtime.py b/src/archaeoai/portal/approved_runtime.py new file mode 100644 index 0000000..c3778fc --- /dev/null +++ b/src/archaeoai/portal/approved_runtime.py @@ -0,0 +1,115 @@ +"""Approved frozen E001 model execution over mathematical synthetic terrain only.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +from archaeoai.inference import FEATURE_COUNT +from archaeoai.inference_system import ( + APPROVED_MODEL_ARTIFACT_SHA256, + FROZEN_MODEL_STATE_SHA256, + ApprovedPrivateRandomForestAdapter, + TerrainInputMetadata, + TerrainPatch, + approved_private_model_reference, + run_approved_single_patch_inference, + transform_single_patch, +) +from archaeoai.inference_system.contracts import ( + APPROVED_MODEL_CONFIG_SHA256, + ModelIdentifier, +) +from archaeoai.portal.demo_runtime import SyntheticDemoRuntime +from archaeoai.portal.model_runtime import ApprovedModelRuntimeExecutionError, RuntimeResult +from archaeoai.portal.schemas import EvidenceLevel, SyntheticScenario + + +@dataclass(frozen=True, slots=True) +class ApprovedPrivateRandomForestRuntime: + """Server-authorized real model runtime with no file or coordinate input surface.""" + + project_root: Path = field(repr=False) + adapter: ApprovedPrivateRandomForestAdapter = field(repr=False) + runtime_name: str = "APPROVED_PRIVATE_RANDOM_FOREST" + result_count: int = 6 + + def validate(self) -> None: + if self.adapter.model_identifier is not ModelIdentifier.E001_FROZEN_RANDOM_FOREST: + raise ApprovedModelRuntimeExecutionError(ApprovedModelRuntimeExecutionError.code) + if self.adapter.model_artifact_sha256 != APPROVED_MODEL_ARTIFACT_SHA256: + raise ApprovedModelRuntimeExecutionError(ApprovedModelRuntimeExecutionError.code) + + def public_status(self) -> dict[str, object]: + return { + "runtime_mode": "APPROVED_PRIVATE_MODEL", + "approved_runtime_enabled": True, + "artifact_verified": True, + "model_execution_available": True, + "model_identifier": self.adapter.model_identifier.value, + "model_config_sha256": APPROVED_MODEL_CONFIG_SHA256[ + ModelIdentifier.E001_FROZEN_RANDOM_FOREST + ], + "model_state_sha256": FROZEN_MODEL_STATE_SHA256, + "input_mode": "SYNTHETIC_ONLY", + } + + def screen(self, scenario: SyntheticScenario, count: int = 6) -> tuple[RuntimeResult, ...]: + if count != self.result_count: + raise ValueError("approved synthetic result count is fixed") + self.validate() + reference = approved_private_model_reference(self.project_root) + results: list[RuntimeResult] = [] + try: + for variant in range(count): + elevation = SyntheticDemoRuntime.surface(scenario, variant) + features = transform_single_patch( + TerrainPatch( + elevation=elevation, + mask=np.zeros_like(elevation, dtype=bool), + metadata=TerrainInputMetadata( + crs="EPSG:27700", + width=128, + height=128, + resolution_m=(1.0, 1.0), + band_count=1, + nodata_fraction=0.0, + ), + ) + ) + if features.feature_vector.shape != (FEATURE_COUNT,): + raise ValueError("canonical feature contract failed") + inference = run_approved_single_patch_inference( + self.project_root, + reference, + features, + self.adapter, + ) + score = inference.terrain_similarity_score + priority = ( + "HIGHER_REVIEW_PRIORITY" + if score >= 0.66 + else "STANDARD_REVIEW_PRIORITY" + if score >= 0.4 + else "LOWER_REVIEW_PRIORITY" + ) + results.append( + RuntimeResult( + score=score, + priority=priority, + evidence_level=EvidenceLevel.AI_OUTPUT, + thumbnail_id=f"synthetic-{scenario.value.casefold()}-{variant + 1}", + warning_code="HUMAN_REVIEW_REQUIRED", + limitation_code="TERRAIN_SIMILARITY_NOT_ARCHAEOLOGICAL_PROBABILITY", + runtime=self.runtime_name, + model_execution="PERFORMED_APPROVED_PRIVATE_MODEL", + ) + ) + del features, elevation + except Exception as exc: + raise ApprovedModelRuntimeExecutionError( + ApprovedModelRuntimeExecutionError.code + ) from exc + return tuple(results) diff --git a/src/archaeoai/portal/demo_runtime.py b/src/archaeoai/portal/demo_runtime.py index 6a2dd9b..04bb35a 100644 --- a/src/archaeoai/portal/demo_runtime.py +++ b/src/archaeoai/portal/demo_runtime.py @@ -18,6 +18,18 @@ class SyntheticDemoRuntime: runtime_name = "SYNTHETIC_DEMO" result_count = 6 + def validate(self) -> None: + return None + + def public_status(self) -> dict[str, object]: + return { + "runtime_mode": self.runtime_name, + "approved_runtime_enabled": False, + "artifact_verified": False, + "model_execution_available": False, + "input_mode": "SYNTHETIC_ONLY", + } + @staticmethod def surface(scenario: SyntheticScenario, variant: int) -> np.ndarray: y, x = np.mgrid[-64:64, -64:64] diff --git a/src/archaeoai/portal/launch.py b/src/archaeoai/portal/launch.py index 8b888ab..d1cad77 100644 --- a/src/archaeoai/portal/launch.py +++ b/src/archaeoai/portal/launch.py @@ -13,13 +13,23 @@ def default_database_path() -> Path: return find_project_root() / "data" / "private" / "portal" / "archaeoai_portal.sqlite3" -def launch_portal(*, demo: bool, reset: bool, port: int, host: str = "127.0.0.1") -> int: +def launch_portal( + *, + demo: bool, + reset: bool, + port: int, + host: str = "127.0.0.1", + approved_model_runtime: bool = False, +) -> int: if not demo: print("ERROR: Phase 6C authorizes only --demo mode.") return 2 if host not in ALLOWED_PORTAL_HOSTS: print("ERROR: portal host must be 127.0.0.1 or 0.0.0.0.") return 2 + if approved_model_runtime and host != "127.0.0.1": + print("ERROR: approved private model runtime is restricted to 127.0.0.1.") + return 2 if not 1024 <= port <= 65535: print("ERROR: portal port must be between 1024 and 65535.") return 2 @@ -30,22 +40,43 @@ def launch_portal(*, demo: bool, reset: bool, port: int, host: str = "127.0.0.1" return 2 from archaeoai.portal.app import create_app + from archaeoai.portal.approved_runtime import ApprovedPrivateRandomForestRuntime from archaeoai.portal.repository import PortalRepository from archaeoai.portal.workflow import PortalWorkflow + approved_runtime = None + if approved_model_runtime: + from archaeoai.inference_system import ( + ApprovedPrivateModelLoadError, + load_approved_private_random_forest, + ) + + try: + project_root = find_project_root() + adapter = load_approved_private_random_forest(project_root) + approved_runtime = ApprovedPrivateRandomForestRuntime(project_root, adapter) + approved_runtime.validate() + except ApprovedPrivateModelLoadError: + print("ERROR: approved private model is unavailable or failed verification.") + return 2 + database = default_database_path() repository = PortalRepository(database) if reset: repository.reset() - PortalWorkflow(repository).seed_demo() + PortalWorkflow(repository, approved_runtime=approved_runtime).seed_demo() if host == "127.0.0.1": print(f"ArchaeoAI portal: http://127.0.0.1:{port}") else: print(f"ArchaeoAI portal listening on 0.0.0.0:{port}") print(f"Web Preview: expose/open port {port}") - print("DEMO MODE — SYNTHETIC DATA · approved model runtime disabled") + if approved_runtime is None: + print("DEMO MODE — SYNTHETIC DATA · approved model runtime disabled") + else: + print("REAL FROZEN MODEL · SYNTHETIC TERRAIN · LOCAL PRIVATE") + print("Score semantics: bounded terrain-pattern similarity; not archaeological probability") uvicorn.run( - create_app(database), + create_app(database, approved_runtime=approved_runtime), host=host, port=port, access_log=False, diff --git a/src/archaeoai/portal/model_runtime.py b/src/archaeoai/portal/model_runtime.py index b26ad68..b36617e 100644 --- a/src/archaeoai/portal/model_runtime.py +++ b/src/archaeoai/portal/model_runtime.py @@ -1,4 +1,4 @@ -"""Portal screening-runtime boundary; the approved runtime is intentionally disabled.""" +"""Portal screening-runtime boundary and safe runtime-status contracts.""" from __future__ import annotations @@ -23,9 +23,15 @@ class RuntimeResult: class ScreeningRuntime(Protocol): runtime_name: str + def validate(self) -> None: + """Validate that the server-authorized runtime remains ready.""" + def screen(self, scenario: SyntheticScenario, count: int = 6) -> tuple[RuntimeResult, ...]: """Return bounded automatic screening records.""" + def public_status(self) -> dict[str, object]: + """Return a fixed, coordinate- and path-free runtime status.""" + class ApprovedModelRuntimeNotAuthorizedError(RuntimeError): """Raised before any artifact lookup, loading, deserialization, or execution.""" @@ -33,6 +39,12 @@ class ApprovedModelRuntimeNotAuthorizedError(RuntimeError): code = "APPROVED_MODEL_RUNTIME_NOT_AUTHORIZED" +class ApprovedModelRuntimeExecutionError(RuntimeError): + """Fixed safe failure raised if verified execution cannot complete.""" + + code = "APPROVED_MODEL_RUNTIME_EXECUTION_FAILED" + + class DisabledApprovedModelRuntime: """Hard-stop production adapter for Phase 6C.""" @@ -45,3 +57,12 @@ def screen(self, scenario: SyntheticScenario, count: int = 6) -> tuple[RuntimeRe def validate(self) -> None: raise ApprovedModelRuntimeNotAuthorizedError(self.code) + + def public_status(self) -> dict[str, object]: + return { + "runtime_mode": "SYNTHETIC_DEMO", + "approved_runtime_enabled": False, + "artifact_verified": False, + "model_execution_available": False, + "input_mode": "SYNTHETIC_ONLY", + } diff --git a/src/archaeoai/portal/repository.py b/src/archaeoai/portal/repository.py index 893723d..e70ee44 100644 --- a/src/archaeoai/portal/repository.py +++ b/src/archaeoai/portal/repository.py @@ -231,14 +231,14 @@ def authorize_demo(self, project_id: str) -> dict: ) return self.get_project(project_id) - def create_job(self, project_id: str, scenario: str) -> dict: + def create_job(self, project_id: str, scenario: str, runtime: str) -> dict: identifier = opaque_id("job") now = utc_now() with self.connect() as db: db.execute( """INSERT INTO jobs - VALUES (?, ?, ?, 'QUEUED', ?, NULL, 'NOT_PERFORMED', 'SYNTHETIC_DEMO')""", - (identifier, project_id, scenario, now), + VALUES (?, ?, ?, 'QUEUED', ?, NULL, 'PENDING', ?)""", + (identifier, project_id, scenario, now, runtime), ) self.add_audit( db, @@ -271,6 +271,13 @@ def list_jobs(self, project_id: str) -> list[dict]: ) def complete_job(self, project_id: str, job_id: str, results: tuple) -> None: + runtimes = {item.runtime for item in results} + executions = {item.model_execution for item in results} + if len(runtimes) != 1 or len(executions) != 1: + raise ValueError("runtime provenance must be consistent within a job") + runtime = runtimes.pop() + model_execution = executions.pop() + approved_execution = model_execution == "PERFORMED_APPROVED_PRIVATE_MODEL" with self.connect() as db: now = utc_now() for item in results: @@ -294,23 +301,47 @@ def complete_job(self, project_id: str, job_id: str, results: tuple) -> None: ) db.execute( """INSERT INTO evidence - VALUES (?, ?, ?, 'Synthetic demo runtime', 'MACHINE', ?, + VALUES (?, ?, ?, ?, 'MACHINE', ?, 'MATHEMATICAL_TERRAIN', 'Terrain morphology prioritized for specialist review', 'ACTIVE', ?)""", - (opaque_id("evd"), project_id, result_id, item.evidence_level.value, now), + ( + opaque_id("evd"), + project_id, + result_id, + "Frozen E001 Random Forest" + if approved_execution + else "Synthetic demo runtime", + item.evidence_level.value, + now, + ), ) db.execute( - "UPDATE jobs SET status='COMPLETED', completed_at=? WHERE id=? AND project_id=?", - (now, job_id, project_id), + """UPDATE jobs SET status='COMPLETED', completed_at=?, + model_execution=?, runtime=? WHERE id=? AND project_id=?""", + (now, model_execution, runtime, job_id, project_id), ) self.add_audit( db, project_id, "PROCESSING_COMPLETED", - "Canonical synthetic feature preparation completed; model execution not performed", + ( + "Frozen E001 model executed on synthetic mathematical terrain" + if approved_execution + else ( + "Canonical synthetic feature preparation completed; " + "model execution not performed" + ) + ), ) self.add_audit( - db, project_id, "RESULTS_CREATED", "Bounded synthetic hypotheses created" + db, + project_id, + "RESULTS_CREATED", + ( + "Bounded synthetic AI outputs created for human review" + if approved_execution + else "Bounded synthetic hypotheses created" + ), ) self.update_project_state(project_id, "REVIEW_REQUIRED", "AWAITING_REVIEW") @@ -450,6 +481,22 @@ def report(self, project_id: str) -> dict: report_row = db.execute( "SELECT * FROM reports WHERE project_id=?", (project_id,) ).fetchone() + runtimes = {result["runtime"] for result in results} + executions = {result["model_execution"] for result in results} + runtime = next(iter(runtimes)) if len(runtimes) == 1 else "NOT_RUN" + model_execution = next(iter(executions)) if len(executions) == 1 else "NOT_PERFORMED" + approved_execution = model_execution == "PERFORMED_APPROVED_PRIVATE_MODEL" + limitations = [ + "Synthetic demonstration — not a professional archaeological assessment.", + "Scores are terrain-pattern similarity values, not archaeological probabilities.", + ] + if approved_execution: + limitations.append( + "The frozen E001 model was executed on synthetic mathematical terrain. This " + "demonstration does not establish the presence or absence of archaeology." + ) + else: + limitations.append("No approved private model or real terrain was used.") return { "status": "READY" if report_row else "NOT_GENERATED", "generated_at": report_row["generated_at"] if report_row else None, @@ -457,15 +504,11 @@ def report(self, project_id: str) -> dict: "screening_summary": { "synthetic_hypotheses": len(results), "reviewed": sum(r["review_state"] in {"REVIEWED", "ESCALATED"} for r in results), - "model_execution": "NOT_PERFORMED", - "runtime": "SYNTHETIC_DEMO", + "model_execution": model_execution, + "runtime": runtime, }, "human_observations": [e for e in evidence if e["actor_type"] == "HUMAN"], - "limitations": [ - "Synthetic demonstration — not a professional archaeological assessment.", - "Scores are deterministic demonstration values, not archaeological probabilities.", - "No approved private model or real terrain was used.", - ], + "limitations": limitations, "provenance": ( "Deterministic mathematical terrain; canonical Phase 5 feature preparation; " "features discarded" diff --git a/src/archaeoai/portal/schemas.py b/src/archaeoai/portal/schemas.py index 9550b15..2132801 100644 --- a/src/archaeoai/portal/schemas.py +++ b/src/archaeoai/portal/schemas.py @@ -56,6 +56,11 @@ class SyntheticScenario(StrEnum): MIXED_MATHEMATICAL = "MIXED_MATHEMATICAL" +class PortalRuntime(StrEnum): + SYNTHETIC_DEMO = "SYNTHETIC_DEMO" + APPROVED_PRIVATE_MODEL = "APPROVED_PRIVATE_MODEL" + + class ProjectStatus(StrEnum): DRAFT = "DRAFT" AUTHORIZED_FOR_DEMO = "AUTHORIZED_FOR_DEMO" @@ -139,7 +144,7 @@ def require_acknowledgements(self) -> DemoAuthorization: class DemoRunRequest(StrictModel): scenario: SyntheticScenario - runtime: str = Field(default="SYNTHETIC_DEMO", pattern=r"^SYNTHETIC_DEMO$") + runtime: PortalRuntime = PortalRuntime.SYNTHETIC_DEMO class ReviewRequest(StrictModel): @@ -165,8 +170,8 @@ class RetentionUpdate(StrictModel): class ApprovedRuntimeRequest(StrictModel): - runtime: str = Field(pattern=r"^APPROVED_MODEL$") - model_identifier: str = Field(min_length=1, max_length=64) + runtime: str = Field(pattern=r"^APPROVED_PRIVATE_MODEL$") + model_identifier: str = Field(pattern=r"^e001-frozen-random-forest$") @field_validator("model_identifier") @classmethod diff --git a/src/archaeoai/portal/static/index.html b/src/archaeoai/portal/static/index.html index 68410bb..b05774f 100644 --- a/src/archaeoai/portal/static/index.html +++ b/src/archaeoai/portal/static/index.html @@ -15,7 +15,7 @@

Evidence-led terrain review, with people in control.

Explore a local workspace using mathematical terrain only. Machine output remains separate from professional archaeological interpretation.

-

DEMO MODE — SYNTHETIC DATA
No production authentication, private model, real terrain, coordinates, or archaeological determination.

+

SYNTHETIC DEMO · MODEL NOT EXECUTED
No production authentication, real terrain, coordinates, or archaeological determination.

ArchaeoAI Demonstration WorkspaceLocal private workspace
-
DEMO MODE — SYNTHETIC DATASystem ready
+
SYNTHETIC DEMO · MODEL NOT EXECUTEDSystem ready
diff --git a/src/archaeoai/portal/static/portal.js b/src/archaeoai/portal/static/portal.js index 155b176..2016e37 100644 --- a/src/archaeoai/portal/static/portal.js +++ b/src/archaeoai/portal/static/portal.js @@ -66,6 +66,21 @@ function statusBadge(status) { return `${escapeHtml(humanize(status))}`; } +function approvedRuntimeAvailable() { + return state.session?.runtime_status?.model_execution_available === true; +} + +function applyRuntimeChrome() { + const approved = approvedRuntimeAvailable(); + const badge = approved ? "REAL FROZEN MODEL · SYNTHETIC TERRAIN · LOCAL PRIVATE" : "SYNTHETIC DEMO · MODEL NOT EXECUTED"; + const copy = approved ? "E001 frozen Random Forest verified; real terrain, coordinates, and remote access remain disabled." : "No production authentication, real terrain, coordinates, or archaeological determination."; + document.querySelector("#entry-runtime-badge").textContent = badge; + document.querySelector("#entry-runtime-copy").textContent = copy; + document.querySelector("#runtime-chip").textContent = badge; + document.querySelector("#sidebar-runtime-title").textContent = approved ? "Approved runtime ready" : "Demo runtime ready"; + document.querySelector("#sidebar-runtime-copy").textContent = approved ? "Real model · synthetic input only" : "Approved model disabled"; +} + function emptyState(title, copy, action = "") { return `

${escapeHtml(title)}

${escapeHtml(copy)}

${action}
`; } @@ -108,12 +123,13 @@ function projectRow(project) { function overviewView() { const o = state.overview; + const approved = approvedRuntimeAvailable(); return `

Workspace overview

Good evidence starts with a controlled workflow.

Manage bounded synthetic demonstrations while preserving the boundary between machine hypotheses and human interpretation.

Active projects${o.active_projects}Local demonstration workspace
Awaiting review${o.awaiting_review}Human attention required
Reports ready${o.reports_ready}Coordinate-safe summaries
-
Approved modelDisabledNo artifact loaded or executed
+
Approved model${approved ? "Ready" : "Disabled"}${approved ? "Verified · local synthetic input only" : "No artifact loaded or executed"}

Recent work

Projects

@@ -123,7 +139,7 @@ function overviewView() {
  1. 1
    AuthorizeRecord scope and synthetic-data attestation.
  2. 2
    ScreenRun canonical feature preparation on mathematical terrain.
  3. 3
    ReviewRecord separately attributed human observations.
  4. 4
    ReportGenerate a limitations-first, coordinate-safe summary.
-
Demonstration boundary

Scores are deterministic interface values—not archaeological probabilities. No real terrain or approved private model is used.

MODEL EXECUTION: NOT PERFORMED
`; +
Demonstration boundary

${approved ? "The frozen E001 model may score synthetic mathematical terrain. Scores are terrain-pattern similarity—not archaeological probabilities." : "Scores are deterministic interface values—not archaeological probabilities. No real terrain or approved private model is used."}

${approved ? "MODEL AVAILABLE · SYNTHETIC ONLY" : "MODEL EXECUTION: NOT PERFORMED"}
`; } function projectsView() { @@ -143,12 +159,13 @@ function terrainThumb(result) { } function resultCard(result) { + const realModel = result.model_execution === "PERFORMED_APPROVED_PRIVATE_MODEL"; return `
${terrainThumb(result)}
${escapeHtml(humanize(result.priority))}${escapeHtml(result.id)}
-
${Number(result.score).toFixed(3)}demonstration score
+
${Number(result.score).toFixed(3)}${realModel ? "terrain similarity score" : "demonstration score"}

${escapeHtml(humanize(result.evidence_level))}
${escapeHtml(humanize(result.review_state))}

-

Terrain-similarity hypothesis only. Not archaeology.

+

${realModel ? "Real frozen model on synthetic terrain. AI output; human review required." : "Terrain-similarity hypothesis only. Not archaeology."}

`; } @@ -162,12 +179,12 @@ function projectSummary() { const p = state.project; return `

Project brief

${escapeHtml(p.name)}

Reference
${escapeHtml(p.project_reference)}
Owner
${escapeHtml(p.owner)}
Purpose
${escapeHtml(p.purpose)}
Retention
${escapeHtml(humanize(p.retention_policy))}
Processing mode
Synthetic mathematical terrain
Authorization
${escapeHtml(humanize(p.authorization_state))}
-
Scope and interpretation

No real terrain, coordinates, private model, archaeological probability, or discovery claim is present.

${escapeHtml(humanize(p.status))}
`; +
Scope and interpretation

${approvedRuntimeAvailable() ? "The verified private model may execute only on generated mathematical terrain. No coordinates, archaeological probability, or discovery claim is present." : "No real terrain, coordinates, private model, archaeological probability, or discovery claim is present."}

${escapeHtml(humanize(p.status))}
`; } function projectAction(project) { if (project.status === "DRAFT") return ``; - if (project.status === "AUTHORIZED_FOR_DEMO") return `
`; + if (project.status === "AUTHORIZED_FOR_DEMO") return `
${approvedRuntimeAvailable() ? '' : ''}
`; return ``; } @@ -228,7 +245,8 @@ function auditView() { } function globalSettingsView() { - return `

System boundaries

Settings

Fixed safeguards for this local demonstration build.

⌂

Local-only service

Bound to loopback. No production authentication, remote deployment, analytics, telemetry, or uploads.

ACTIVE
◇

Approved model runtime

Fail-closed boundary. No artifact path, loading, deserialization, or execution is available.

DISABLED
▱

Private storage

Minimal SQLite state under an ignored private directory. No coordinates, rasters, paths, or feature vectors.

LOCAL
◎

Evidence ceiling

Machine outputs stop at AI hypothesis. Demo human review can record only a human-vetted observation.

ENFORCED
`; + const approved = approvedRuntimeAvailable(); + return `

System boundaries

Settings

Fixed safeguards for this local demonstration build.

⌂

Local-only service

Bound to loopback. No production authentication, remote deployment, analytics, telemetry, or uploads.

ACTIVE
◇

Approved model runtime

${approved ? "Verified frozen E001 Random Forest. Execution is limited to synthetic terrain on this local server." : "Fail-closed boundary. No artifact path, loading, deserialization, or execution is available."}

${approved ? "VERIFIED" : "DISABLED"}
▱

Private storage

Minimal SQLite state under an ignored private directory. No coordinates, rasters, paths, or feature vectors.

LOCAL
◎

Evidence ceiling

Automatic results stop at machine evidence. Demo human review can record only a human-vetted observation.

ENFORCED
`; } function render() { @@ -252,6 +270,7 @@ function formJson(form) { document.querySelector("#enter-workspace").addEventListener("click", async () => { try { state.session = await api("/session"); + applyRuntimeChrome(); await refreshBase(); document.querySelector("#organization-name").textContent = state.session.organization.name; document.querySelector("#entry-view").hidden = true; @@ -276,8 +295,9 @@ document.addEventListener("click", async (event) => { } if (button.dataset.runDemo) { button.disabled = true; button.textContent = "Preparing synthetic terrain…"; - await api(`/projects/${button.dataset.runDemo}/run-demo`, { method: "POST", body: JSON.stringify({ scenario: document.querySelector("#scenario").value, runtime: "SYNTHETIC_DEMO" }) }); - toast("Synthetic workflow completed. No model was executed."); await refreshBase(); await openProject(button.dataset.runDemo, "results"); + const runtime = document.querySelector("#runtime")?.value || "SYNTHETIC_DEMO"; + await api(`/projects/${button.dataset.runDemo}/run-demo`, { method: "POST", body: JSON.stringify({ scenario: document.querySelector("#scenario").value, runtime }) }); + toast(runtime === "APPROVED_PRIVATE_MODEL" ? "Frozen E001 model executed on synthetic terrain. Human review required." : "Synthetic workflow completed. No model was executed."); await refreshBase(); await openProject(button.dataset.runDemo, "results"); } if (button.dataset.reviewResult) { const resultId = button.dataset.reviewResult; @@ -288,7 +308,7 @@ document.addEventListener("click", async (event) => { } if (button.dataset.inspectResult) { const result = await api(`/results/${button.dataset.inspectResult}`); - document.querySelector("#result-detail").innerHTML = `${terrainThumb(result)}
Opaque result ID
${escapeHtml(result.id)}
Demonstration score
${Number(result.score).toFixed(3)}
Review priority
${escapeHtml(humanize(result.priority))}
Evidence level
${escapeHtml(humanize(result.evidence_level))}
Runtime
${escapeHtml(result.runtime)}
Model execution
${escapeHtml(result.model_execution)}

Terrain morphology prioritized for specialist review. Synthetic demonstration only—not archaeology or archaeological probability.

`; + document.querySelector("#result-detail").innerHTML = `${terrainThumb(result)}
Opaque result ID
${escapeHtml(result.id)}
Terrain similarity score
${Number(result.score).toFixed(3)}
Review priority
${escapeHtml(humanize(result.priority))}
Evidence level
${escapeHtml(humanize(result.evidence_level))}
Runtime
${escapeHtml(result.runtime)}
Model execution
${escapeHtml(result.model_execution)}

Terrain morphology prioritized for specialist review. Synthetic terrain only—not archaeology or archaeological probability.

`; document.querySelector("#result-dialog").showModal(); } if (button.hasAttribute("data-generate-report")) { @@ -350,8 +370,10 @@ workspace.addEventListener("input", (event) => { document.querySelector("#mobile-menu").addEventListener("click", () => document.querySelector(".sidebar").classList.toggle("open")); +api("/session").then((session) => { state.session = session; applyRuntimeChrome(); }).catch(() => {}); + // Optional local browser-agent affordances. These mirror the same bounded APIs and -// never expose terrain, coordinates, features, or an approved model runtime. +// never expose terrain, coordinates, features, model paths, or authorization controls. if (navigator.modelContext?.registerTool) { navigator.modelContext.registerTool({ name: "list_demo_projects", diff --git a/src/archaeoai/portal/workflow.py b/src/archaeoai/portal/workflow.py index bfe7ea4..a1204e9 100644 --- a/src/archaeoai/portal/workflow.py +++ b/src/archaeoai/portal/workflow.py @@ -1,25 +1,40 @@ -"""Application workflow enforcing synthetic-only processing and evidence boundaries.""" +"""Application workflow enforcing synthetic-input and evidence boundaries.""" from __future__ import annotations from archaeoai.portal.demo_runtime import SyntheticDemoRuntime -from archaeoai.portal.model_runtime import DisabledApprovedModelRuntime +from archaeoai.portal.model_runtime import DisabledApprovedModelRuntime, ScreeningRuntime from archaeoai.portal.repository import PortalRepository -from archaeoai.portal.schemas import DemoRunRequest, EvidenceLevel, ReviewRequest +from archaeoai.portal.schemas import DemoRunRequest, EvidenceLevel, PortalRuntime, ReviewRequest class PortalWorkflow: - def __init__(self, repository: PortalRepository): + def __init__( + self, + repository: PortalRepository, + *, + approved_runtime: ScreeningRuntime | None = None, + ): self.repository = repository self.demo_runtime = SyntheticDemoRuntime() - self.approved_runtime = DisabledApprovedModelRuntime() + self.approved_runtime = approved_runtime or DisabledApprovedModelRuntime() + + def runtime_status(self) -> dict[str, object]: + return self.approved_runtime.public_status() def run_demo(self, project_id: str, request: DemoRunRequest) -> dict: project = self.repository.get_project(project_id) if project["status"] != "AUTHORIZED_FOR_DEMO": raise ValueError("DEMO_AUTHORIZATION_REQUIRED") - job = self.repository.create_job(project_id, request.scenario.value) - results = self.demo_runtime.screen(request.scenario) + runtime: ScreeningRuntime = ( + self.approved_runtime + if request.runtime is PortalRuntime.APPROVED_PRIVATE_MODEL + else self.demo_runtime + ) + if request.runtime is PortalRuntime.APPROVED_PRIVATE_MODEL: + self.approved_runtime.validate() + job = self.repository.create_job(project_id, request.scenario.value, runtime.runtime_name) + results = runtime.screen(request.scenario) if any( item.evidence_level not in {EvidenceLevel.AI_OUTPUT, EvidenceLevel.AI_HYPOTHESIS} for item in results @@ -38,7 +53,7 @@ def seed_demo(self) -> None: return seeds = ( ( - "prj_demo0000001", + "prj_demo00000001", "Demo Terrain Assessment A", "Explore a synthetic terrain screening and human review workflow.", "DEMO-A", @@ -46,7 +61,7 @@ def seed_demo(self) -> None: "MOUND_LIKE", ), ( - "prj_demo0000002", + "prj_demo00000002", "Infrastructure Screening Demo", "Demonstrate bounded mixed mathematical terrain processing.", "DEMO-B", @@ -67,7 +82,8 @@ def seed_demo(self) -> None: ) self.repository.authorize_demo(project["id"]) self.run_demo( - project["id"], DemoRunRequest(scenario=scenario, runtime="SYNTHETIC_DEMO") + project["id"], + DemoRunRequest(scenario=scenario, runtime=PortalRuntime.SYNTHETIC_DEMO), ) self.repository.create_project( { @@ -79,5 +95,5 @@ def seed_demo(self) -> None: "project_reference": "DEMO-C", "retention_policy": "THIRTY_DAYS", }, - project_id="prj_demo0000003", + project_id="prj_demo00000003", ) diff --git a/tests/test_phase6c_commercial_portal.py b/tests/test_phase6c_commercial_portal.py index 1ddf20f..0a8c1a7 100644 --- a/tests/test_phase6c_commercial_portal.py +++ b/tests/test_phase6c_commercial_portal.py @@ -30,6 +30,7 @@ ReviewRequest, SyntheticScenario, ) +from archaeoai.portal.workflow import PortalWorkflow MUTATION_HEADERS = {"X-ArchaeoAI-Demo": "1"} FORBIDDEN_RESPONSE_KEYS = { @@ -117,6 +118,13 @@ def test_health_and_session_are_explicitly_local_demo(client: TestClient) -> Non "demo_runtime": "AVAILABLE", "approved_model_runtime": "DISABLED_NOT_AUTHORIZED", "storage": "LOCAL_PRIVATE_SQLITE", + "runtime_status": { + "runtime_mode": "SYNTHETIC_DEMO", + "approved_runtime_enabled": False, + "artifact_verified": False, + "model_execution_available": False, + "input_mode": "SYNTHETIC_ONLY", + }, } assert client.get("/api/v1/session").json()["authenticated"] is False @@ -195,6 +203,20 @@ def test_project_lifecycle_create_read_delete(client: TestClient) -> None: assert client.get(f"/api/v1/projects/{project['id']}").status_code == 404 +def test_seeded_demo_project_ids_satisfy_public_route_contract(tmp_path: Path) -> None: + database = tmp_path / "seeded.sqlite3" + repository = PortalRepository(database) + workflow = PortalWorkflow(repository) + workflow.seed_demo() + with TestClient(create_app(database)) as seeded_client: + projects = seeded_client.get("/api/v1/projects").json() + assert len(projects) == 3 + assert all( + seeded_client.get(f"/api/v1/projects/{project['id']}").status_code == 200 + for project in projects + ) + + def test_state_change_requires_demo_header(client: TestClient) -> None: response = client.post("/api/v1/projects", json=project_payload()) assert response.status_code == 403 @@ -262,7 +284,10 @@ def test_synthetic_surface_uses_canonical_phase5_feature_contract() -> None: def test_approved_model_runtime_fails_before_artifact_operations(client: TestClient) -> None: response = client.post( "/api/v1/runtime/approved/check", - json={"runtime": "APPROVED_MODEL", "model_identifier": "approved-reference"}, + json={ + "runtime": "APPROVED_PRIVATE_MODEL", + "model_identifier": "e001-frozen-random-forest", + }, headers=MUTATION_HEADERS, ) assert response.status_code == 403 @@ -478,16 +503,18 @@ def test_database_schema_has_no_spatial_path_raster_or_feature_columns(tmp_path: def test_approved_runtime_schema_rejects_paths_urls_and_unknown_fields() -> None: for value in ("../../model.pkl", "file:///model.pkl", "https://example.test/model"): with pytest.raises(ValidationError): - ApprovedRuntimeRequest(runtime="APPROVED_MODEL", model_identifier=value) + ApprovedRuntimeRequest(runtime="APPROVED_PRIVATE_MODEL", model_identifier=value) with pytest.raises(ValidationError): ApprovedRuntimeRequest( - runtime="APPROVED_MODEL", model_identifier="safe-id", model_path="private.pkl" + runtime="APPROVED_PRIVATE_MODEL", + model_identifier="e001-frozen-random-forest", + model_path="private.pkl", ) def test_arbitrary_runtime_name_is_rejected() -> None: with pytest.raises(ValidationError): - DemoRunRequest(scenario="PLANAR", runtime="APPROVED_MODEL") + DemoRunRequest(scenario="PLANAR", runtime="INJECTED_MODEL") def test_project_acknowledgements_are_mandatory() -> None: @@ -503,7 +530,7 @@ def test_static_portal_contains_required_navigation_and_safe_language(client: Te stylesheet = client.get("/static/portal.css").text for label in ("Overview", "Projects", "Review Queue", "Reports", "Audit", "Settings"): assert label in html - assert "DEMO MODE — SYNTHETIC DATA" in html + assert "SYNTHETIC DEMO · MODEL NOT EXECUTED" in html assert "Import authorized terrain" in html assert "Live terrain ingestion is not enabled" in html assert "HUMAN_VETTED_OBSERVATION" in html diff --git a/tests/test_phase6d_portal_runtime.py b/tests/test_phase6d_portal_runtime.py new file mode 100644 index 0000000..3cd9994 --- /dev/null +++ b/tests/test_phase6d_portal_runtime.py @@ -0,0 +1,351 @@ +"""Phase 6D portal authorization, execution, and disclosure-boundary tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest +from fastapi.testclient import TestClient + +import archaeoai.inference_system.single_patch as single_patch +from archaeoai.cli import build_parser +from archaeoai.inference import FEATURE_COUNT +from archaeoai.inference_system import APPROVED_MODEL_ARTIFACT_SHA256, ApprovedPrivateModelLoadError +from archaeoai.inference_system.contracts import ( + APPROVED_MODEL_CONFIG_SHA256, + ModelIdentifier, +) +from archaeoai.portal.app import create_app +from archaeoai.portal.approved_runtime import ApprovedPrivateRandomForestRuntime +from archaeoai.portal.demo_runtime import SyntheticDemoRuntime +from archaeoai.portal.launch import launch_portal + +HEADERS = {"X-ArchaeoAI-Demo": "1"} +FORBIDDEN = { + "coordinates", + "bounds", + "transform", + "features", + "feature_vector", + "model_path", + "file_path", + "artifact_bytes", +} + + +class RecordingAdapter: + model_identifier = ModelIdentifier.E001_FROZEN_RANDOM_FOREST + model_config_sha256 = APPROVED_MODEL_CONFIG_SHA256[model_identifier] + model_artifact_sha256 = APPROVED_MODEL_ARTIFACT_SHA256 + + def __init__(self, score: float = 0.72): + self.score = score + self.calls: list[tuple[tuple[int, ...], np.dtype, bool]] = [] + + def score_model_input(self, feature_matrix: np.ndarray) -> float: + self.calls.append( + (feature_matrix.shape, feature_matrix.dtype, feature_matrix.flags.writeable) + ) + return self.score + + +def _walk_keys(value: object) -> set[str]: + if isinstance(value, dict): + return {str(key).casefold() for key in value} | set().union( + *(_walk_keys(item) for item in value.values()), set() + ) + if isinstance(value, list): + return set().union(*(_walk_keys(item) for item in value), set()) + return set() + + +def _approved_client( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, score: float = 0.72 +) -> tuple[TestClient, RecordingAdapter]: + monkeypatch.setattr(single_patch, "verify_approved_model_artifact", lambda *_args: "verified") + adapter = RecordingAdapter(score) + runtime = ApprovedPrivateRandomForestRuntime(tmp_path, adapter) # type: ignore[arg-type] + return TestClient(create_app(tmp_path / "portal.sqlite3", approved_runtime=runtime)), adapter + + +def _create_authorized_project(client: TestClient) -> str: + created = client.post( + "/api/v1/projects", + headers=HEADERS, + json={ + "project_name": "Approved runtime synthetic check", + "organization": "Local test", + "purpose": "Verify the bounded approved runtime over mathematical terrain.", + "project_reference": "P6D-TEST", + "authorized_data_confirmation": True, + "retention_policy": "SESSION_ONLY", + "reviewer_requirement_acknowledgement": True, + }, + ) + project_id = created.json()["id"] + authorized = client.post( + f"/api/v1/projects/{project_id}/authorize-demo", + headers=HEADERS, + json={ + "authorized_data_confirmation": True, + "reviewer_requirement_acknowledgement": True, + }, + ) + assert authorized.status_code == 200 + return str(project_id) + + +def test_approved_cli_flag_is_explicit_and_default_is_disabled() -> None: + default = build_parser().parse_args(["portal", "--demo"]) + approved = build_parser().parse_args(["portal", "--demo", "--approved-model-runtime"]) + assert default.approved_model_runtime is False + assert approved.approved_model_runtime is True + + +def test_approved_runtime_rejects_public_bind_before_server( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + called = False + + def forbidden(*_args: object, **_kwargs: object) -> None: + nonlocal called + called = True + + monkeypatch.setattr("uvicorn.run", forbidden) + assert ( + launch_portal( + demo=True, + reset=False, + port=8000, + host="0.0.0.0", + approved_model_runtime=True, + ) + == 2 + ) + assert called is False + assert "restricted to 127.0.0.1" in capsys.readouterr().out + + +def test_missing_approved_artifact_stops_launcher_without_fallback( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + server_started = False + + def unavailable(_root: Path) -> object: + raise ApprovedPrivateModelLoadError(ApprovedPrivateModelLoadError.code) + + def forbidden_server(*_args: object, **_kwargs: object) -> None: + nonlocal server_started + server_started = True + + monkeypatch.setattr( + "archaeoai.inference_system.load_approved_private_random_forest", unavailable + ) + monkeypatch.setattr("archaeoai.portal.launch.default_database_path", lambda: tmp_path / "db") + monkeypatch.setattr("uvicorn.run", forbidden_server) + assert ( + launch_portal( + demo=True, + reset=False, + port=8000, + approved_model_runtime=True, + ) + == 2 + ) + assert server_started is False + output = capsys.readouterr().out + assert "unavailable or failed verification" in output + assert str(tmp_path) not in output + + +def test_verified_approved_runtime_reaches_local_server_only( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + captured: dict[str, object] = {} + adapter = RecordingAdapter() + monkeypatch.setattr( + "archaeoai.inference_system.load_approved_private_random_forest", + lambda _root: adapter, + ) + monkeypatch.setattr("archaeoai.portal.launch.find_project_root", lambda: tmp_path) + monkeypatch.setattr("archaeoai.portal.launch.default_database_path", lambda: tmp_path / "db") + monkeypatch.setattr("uvicorn.run", lambda _app, **kwargs: captured.update(kwargs)) + assert ( + launch_portal( + demo=True, + reset=False, + port=8000, + approved_model_runtime=True, + ) + == 0 + ) + assert captured["host"] == "127.0.0.1" + output = capsys.readouterr().out + assert "REAL FROZEN MODEL · SYNTHETIC TERRAIN · LOCAL PRIVATE" in output + assert "not archaeological probability" in output + + +def test_api_request_cannot_authorize_disabled_runtime(tmp_path: Path) -> None: + with TestClient(create_app(tmp_path / "disabled.sqlite3")) as client: + project_id = _create_authorized_project(client) + response = client.post( + f"/api/v1/projects/{project_id}/run-demo", + headers=HEADERS, + json={"scenario": "PLANAR", "runtime": "APPROVED_PRIVATE_MODEL"}, + ) + assert response.status_code == 403 + assert response.json()["error"] == "APPROVED_MODEL_RUNTIME_NOT_AUTHORIZED" + assert client.get(f"/api/v1/projects/{project_id}/jobs").json() == [] + + +def test_approved_status_is_safe_and_server_injected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + client, _adapter = _approved_client(monkeypatch, tmp_path) + with client: + status = client.get("/api/v1/health").json()["runtime_status"] + assert status == { + "runtime_mode": "APPROVED_PRIVATE_MODEL", + "approved_runtime_enabled": True, + "artifact_verified": True, + "model_execution_available": True, + "model_identifier": "e001-frozen-random-forest", + "model_config_sha256": ( + "20cd377c17373eeeb5403c84119084287f193d93b42c8004d99c823e01a157e4" + ), + "model_state_sha256": ( + "e3b0c072f437e889f09a2a2cf5a37f19b2f483eb5188e102b132a89ee76d1939" + ), + "input_mode": "SYNTHETIC_ONLY", + } + serialized = json.dumps(status).casefold() + assert "c:\\" not in serialized + assert "/users/" not in serialized + assert "data/private" not in serialized + + +def test_approved_runtime_uses_canonical_features_and_real_adapter_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + client, adapter = _approved_client(monkeypatch, tmp_path) + monkeypatch.setattr( + SyntheticDemoRuntime, + "_demonstration_score", + lambda *_args: (_ for _ in ()).throw(AssertionError("fallback scorer called")), + ) + with client: + project_id = _create_authorized_project(client) + response = client.post( + f"/api/v1/projects/{project_id}/run-demo", + headers=HEADERS, + json={"scenario": "MOUND_LIKE", "runtime": "APPROVED_PRIVATE_MODEL"}, + ) + assert response.status_code == 200 + results = client.get(f"/api/v1/projects/{project_id}/results").json() + assert len(adapter.calls) == len(results) == 6 + assert set(adapter.calls) == {((1, FEATURE_COUNT), np.dtype("float32"), False)} + assert {item["evidence_level"] for item in results} == {"AI_OUTPUT"} + assert {item["runtime"] for item in results} == {"APPROVED_PRIVATE_RANDOM_FOREST"} + assert {item["model_execution"] for item in results} == {"PERFORMED_APPROVED_PRIVATE_MODEL"} + assert all(item["score"] == pytest.approx(0.72) for item in results) + assert not (_walk_keys(results) & FORBIDDEN) + + +def test_approved_report_preserves_safe_model_provenance( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + client, _adapter = _approved_client(monkeypatch, tmp_path) + with client: + project_id = _create_authorized_project(client) + client.post( + f"/api/v1/projects/{project_id}/run-demo", + headers=HEADERS, + json={"scenario": "MIXED_MATHEMATICAL", "runtime": "APPROVED_PRIVATE_MODEL"}, + ) + report = client.post(f"/api/v1/projects/{project_id}/report", headers=HEADERS).json() + assert report["screening_summary"] == { + "synthetic_hypotheses": 6, + "reviewed": 0, + "model_execution": "PERFORMED_APPROVED_PRIVATE_MODEL", + "runtime": "APPROVED_PRIVATE_RANDOM_FOREST", + } + assert any("does not establish" in item for item in report["limitations"]) + assert not (_walk_keys(report) & FORBIDDEN) + + +@pytest.mark.parametrize("score", [float("nan"), -0.01, 1.01]) +def test_invalid_approved_score_returns_only_safe_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, score: float +) -> None: + client, _adapter = _approved_client(monkeypatch, tmp_path, score=score) + with client: + project_id = _create_authorized_project(client) + response = client.post( + f"/api/v1/projects/{project_id}/run-demo", + headers=HEADERS, + json={"scenario": "PLANAR", "runtime": "APPROVED_PRIVATE_MODEL"}, + ) + assert response.status_code == 503 + assert response.json() == { + "error": "APPROVED_MODEL_RUNTIME_EXECUTION_FAILED", + "message": "Approved runtime failed safely.", + } + + +@pytest.mark.parametrize( + "runtime", + ["../../model.pkl", "APPROVED_PRIVATE_MODEL/../../x", "RANDOM_FOREST", "approved"], +) +def test_runtime_injection_and_traversal_are_rejected(tmp_path: Path, runtime: str) -> None: + with TestClient(create_app(tmp_path / "strict.sqlite3")) as client: + project_id = _create_authorized_project(client) + response = client.post( + f"/api/v1/projects/{project_id}/run-demo", + headers=HEADERS, + json={"scenario": "PLANAR", "runtime": runtime}, + ) + assert response.status_code == 422 + assert response.json()["error"] == "INVALID_REQUEST" + + +def test_http_contract_never_accepts_model_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + client, _adapter = _approved_client(monkeypatch, tmp_path) + with client: + project_id = _create_authorized_project(client) + response = client.post( + f"/api/v1/projects/{project_id}/run-demo", + headers=HEADERS, + json={ + "scenario": "PLANAR", + "runtime": "APPROVED_PRIVATE_MODEL", + "model_path": "../../private.pkl", + }, + ) + assert response.status_code == 422 + + +def test_static_routes_do_not_expose_private_artifacts(tmp_path: Path) -> None: + with TestClient(create_app(tmp_path / "routes.sqlite3")) as client: + for route in ( + "/static/model.pkl", + "/data/private/e001/inference/e001_phase2f_random_forest.pkl", + "/api/v1/model/download", + ): + response = client.get(route) + assert APPROVED_MODEL_ARTIFACT_SHA256 not in response.text + assert not response.content.startswith(b"\x80\x05") + + +def test_no_cors_wildcard_or_network_dependency(tmp_path: Path) -> None: + with TestClient(create_app(tmp_path / "network.sqlite3")) as client: + response = client.get("/api/v1/health") + assert response.headers.get("access-control-allow-origin") != "*" + assert "http" not in json.dumps(response.json()).casefold() diff --git a/tests/test_phase6d_private_model_adapter.py b/tests/test_phase6d_private_model_adapter.py new file mode 100644 index 0000000..feb466f --- /dev/null +++ b/tests/test_phase6d_private_model_adapter.py @@ -0,0 +1,179 @@ +"""Phase 6D tests for the hash-bound private Random Forest adapter.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from sklearn.ensemble import RandomForestClassifier + +import archaeoai.inference_system.private_model_adapter as private_adapter +from archaeoai.inference import FEATURE_COUNT +from archaeoai.inference_system import ( + APPROVED_MODEL_ARTIFACT_SHA256, + FROZEN_MODEL_STATE_SHA256, + ApprovedPrivateModelLoadError, + ApprovedPrivateRandomForestAdapter, + ModelIdentifier, + approved_private_model_reference, + load_approved_private_random_forest, +) + + +def _project_with_private_placeholder(tmp_path: Path) -> Path: + target = tmp_path / "data/private/e001/inference/e001_phase2f_random_forest.pkl" + target.parent.mkdir(parents=True) + target.write_bytes(b"inert-test-placeholder") + return tmp_path + + +def _patch_preload_gates( + monkeypatch: pytest.MonkeyPatch, + *, + estimator: object, +) -> list[str]: + calls: list[str] = [] + + def ignored(_root: Path, _path: Path) -> None: + calls.append("ignored") + + def verified(_root: Path, _reference: object) -> str: + calls.append("artifact_verified") + return APPROVED_MODEL_ARTIFACT_SHA256 + + def loaded(*_args: object, **_kwargs: object) -> object: + calls.append("deserialized") + return estimator + + monkeypatch.setattr(private_adapter, "verify_git_ignored", ignored) + monkeypatch.setattr(private_adapter, "verify_approved_model_artifact", verified) + monkeypatch.setattr(private_adapter, "load_private_model", loaded) + return calls + + +def test_approved_reference_is_fixed_and_accepts_no_model_path(tmp_path: Path) -> None: + reference = approved_private_model_reference(tmp_path) + assert reference.model_identifier is ModelIdentifier.E001_FROZEN_RANDOM_FOREST + assert reference.model_config_sha256 == ( + "20cd377c17373eeeb5403c84119084287f193d93b42c8004d99c823e01a157e4" + ) + assert ( + reference.path + == (tmp_path / "data/private/e001/inference/e001_phase2f_random_forest.pkl").resolve() + ) + + +def test_artifact_mismatch_stops_before_deserialization( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + root = _project_with_private_placeholder(tmp_path) + loaded = False + monkeypatch.setattr(private_adapter, "verify_git_ignored", lambda *_args: None) + monkeypatch.setattr( + private_adapter, + "verify_approved_model_artifact", + lambda *_args: (_ for _ in ()).throw(ValueError("mismatch")), + ) + + def forbidden_load(*_args: object, **_kwargs: object) -> object: + nonlocal loaded + loaded = True + return object() + + monkeypatch.setattr(private_adapter, "load_private_model", forbidden_load) + with pytest.raises(ApprovedPrivateModelLoadError, match="APPROVED_PRIVATE_MODEL_UNAVAILABLE"): + load_approved_private_random_forest(root) + assert loaded is False + + +def test_missing_artifact_fails_closed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(private_adapter, "verify_git_ignored", lambda *_args: None) + with pytest.raises(ApprovedPrivateModelLoadError, match="APPROVED_PRIVATE_MODEL_UNAVAILABLE"): + load_approved_private_random_forest(tmp_path) + + +def test_unexpected_estimator_class_fails_closed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + root = _project_with_private_placeholder(tmp_path) + calls = _patch_preload_gates(monkeypatch, estimator=object()) + with pytest.raises(ApprovedPrivateModelLoadError): + load_approved_private_random_forest(root) + assert calls == ["ignored", "artifact_verified", "deserialized"] + + +def test_model_state_mismatch_fails_closed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + root = _project_with_private_placeholder(tmp_path) + estimator = RandomForestClassifier() + _patch_preload_gates(monkeypatch, estimator=estimator) + monkeypatch.setattr(private_adapter, "model_state_sha256", lambda _model: "0" * 64) + with pytest.raises(ApprovedPrivateModelLoadError): + load_approved_private_random_forest(root) + + +def test_valid_load_order_and_adapter_identity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + root = _project_with_private_placeholder(tmp_path) + estimator = RandomForestClassifier() + calls = _patch_preload_gates(monkeypatch, estimator=estimator) + monkeypatch.setattr( + private_adapter, "model_state_sha256", lambda _model: FROZEN_MODEL_STATE_SHA256 + ) + adapter = load_approved_private_random_forest(root) + assert calls == ["ignored", "artifact_verified", "deserialized"] + assert adapter.model_identifier is ModelIdentifier.E001_FROZEN_RANDOM_FOREST + assert adapter.model_artifact_sha256 == APPROVED_MODEL_ARTIFACT_SHA256 + + +def test_adapter_rejects_config_identity_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: + estimator = RandomForestClassifier() + monkeypatch.setattr( + private_adapter, "model_state_sha256", lambda _model: FROZEN_MODEL_STATE_SHA256 + ) + with pytest.raises(ValueError, match="configuration identity mismatch"): + ApprovedPrivateRandomForestAdapter(estimator, model_config_sha256="0" * 64) + + +def test_adapter_preserves_exact_read_only_model_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + estimator = RandomForestClassifier() + monkeypatch.setattr( + private_adapter, "model_state_sha256", lambda _model: FROZEN_MODEL_STATE_SHA256 + ) + observed: dict[str, object] = {} + + def score(_model: RandomForestClassifier, matrix: np.ndarray) -> np.ndarray: + observed.update(shape=matrix.shape, dtype=matrix.dtype, writeable=matrix.flags.writeable) + return np.asarray([0.625], dtype=np.float64) + + monkeypatch.setattr(private_adapter, "score_frozen_feature_matrix", score) + adapter = ApprovedPrivateRandomForestAdapter(estimator) + matrix = np.zeros((1, FEATURE_COUNT), dtype=np.float32) + matrix.setflags(write=False) + assert adapter.score_model_input(matrix) == 0.625 + assert observed == { + "shape": (1, FEATURE_COUNT), + "dtype": np.dtype("float32"), + "writeable": False, + } + + +@pytest.mark.parametrize("score", [float("nan"), -0.1, 1.1]) +def test_invalid_model_output_is_rejected(monkeypatch: pytest.MonkeyPatch, score: float) -> None: + estimator = RandomForestClassifier() + monkeypatch.setattr( + private_adapter, "model_state_sha256", lambda _model: FROZEN_MODEL_STATE_SHA256 + ) + monkeypatch.setattr( + private_adapter, + "score_frozen_feature_matrix", + lambda _model, _matrix: np.asarray([score], dtype=np.float64), + ) + adapter = ApprovedPrivateRandomForestAdapter(estimator) + matrix = np.zeros((1, FEATURE_COUNT), dtype=np.float32) + matrix.setflags(write=False) + with pytest.raises(ValueError): + adapter.score_model_input(matrix)