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 @@
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.
${escapeHtml(copy)}
${action}Workspace overview
Manage bounded synthetic demonstrations while preserving the boundary between machine hypotheses and human interpretation.
Recent work
${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."}
Project brief
System boundaries
Fixed safeguards for this local demonstration build.
Bound to loopback. No production authentication, remote deployment, analytics, telemetry, or uploads.
ACTIVEFail-closed boundary. No artifact path, loading, deserialization, or execution is available.
DISABLEDMinimal SQLite state under an ignored private directory. No coordinates, rasters, paths, or feature vectors.
LOCALMachine outputs stop at AI hypothesis. Demo human review can record only a human-vetted observation.
ENFORCEDSystem boundaries
Fixed safeguards for this local demonstration build.
Bound to loopback. No production authentication, remote deployment, analytics, telemetry, or uploads.
ACTIVE${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"}Minimal SQLite state under an ignored private directory. No coordinates, rasters, paths, or feature vectors.
LOCALAutomatic results stop at machine evidence. Demo human review can record only a human-vetted observation.
ENFORCEDTerrain morphology prioritized for specialist review. Synthetic demonstration only—not archaeology or archaeological probability.
`; + document.querySelector("#result-detail").innerHTML = `${terrainThumb(result)}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)