diff --git a/README.md b/README.md index f88d49f..862a073 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Noesis is the Abraxas latent-representation research module: a contract-first sy - Canonical status: `CANON-SHADOW` - Governance effect: `ADVISORY_ONLY` -- Runtime: `SLICE-001 + SLICE-002A + SLICE-003 + SLICE-004 + SLICE-005 + SLICE-006 + SLICE-007 + SLICE-008 + SLICE-009 + SLICE-010 + SLICE-011 + SLICE-012 + SLICE-013 + SLICE-014 + SLICE-015 + SLICE-016 + SLICE-017 + SLICE-018 + SLICE-019 + SLICE-020 + SLICE-021 + SLICE-022 IMPLEMENTED` +- Runtime: `SLICE-001 + SLICE-002A + SLICE-003 + SLICE-004 + SLICE-005 + SLICE-006 + SLICE-007 + SLICE-008 + SLICE-009 + SLICE-010 + SLICE-011 + SLICE-012 + SLICE-013 + SLICE-014 + SLICE-015 + SLICE-016 + SLICE-017 + SLICE-018 + SLICE-019 + SLICE-020 + SLICE-021 + SLICE-022 + SLICE-023 + SLICE-024 + SLICE-025 + SLICE-026 + SLICE-027 IMPLEMENTED` - Specification: `COMPLETE` - N5 latent communication: `BLOCKED` pending AC-N4 + explicit operator authorization diff --git a/STATUS.md b/STATUS.md index 7517219..bad3515 100644 --- a/STATUS.md +++ b/STATUS.md @@ -6,7 +6,7 @@ - Governance effect: `ADVISORY_ONLY` - Foundation spec: `COMPLETE` - Canonical requirements/journeys/workflows/state machines/contracts/data/security/acceptance/tasks/verification: `COMPLETE` -- Runtime implementation: `SLICE-001 + SLICE-002A + SLICE-003 + SLICE-004 + SLICE-005 + SLICE-006 + SLICE-007 + SLICE-008 + SLICE-009 + SLICE-010 + SLICE-011 + SLICE-012 + SLICE-013 + SLICE-014 + SLICE-015 + SLICE-016 + SLICE-017 + SLICE-018 + SLICE-019 + SLICE-020 + SLICE-021 + SLICE-022 IMPLEMENTED` +- Runtime implementation: `SLICE-001 + SLICE-002A + SLICE-003 + SLICE-004 + SLICE-005 + SLICE-006 + SLICE-007 + SLICE-008 + SLICE-009 + SLICE-010 + SLICE-011 + SLICE-012 + SLICE-013 + SLICE-014 + SLICE-015 + SLICE-016 + SLICE-017 + SLICE-018 + SLICE-019 + SLICE-020 + SLICE-021 + SLICE-022 + SLICE-023 + SLICE-024 + SLICE-025 + SLICE-026 + SLICE-027 IMPLEMENTED` - Hyperlex integration: `ADAPTER + PREREGISTRATION BOUNDARY IMPLEMENTED / TRAINED MODEL BINDING PENDING` - N0 embedding/representation comparison: `AC-N0 ACCEPTED / PINNED REAL-MODEL EVIDENCE` - N1 hidden-state capture: `AC-N1 ACCEPTED / PINNED REAL-MODEL EVIDENCE` @@ -219,6 +219,24 @@ Implemented: This does not authorize N5 science. It only enforces the gate. +### SLICE-023 — Replication environment fingerprints +Implemented: +- optional original/replica environment maps on `replicate_settlement`; +- `require_equivalent_environment` blocks full `REPLICATED` when fingerprints differ (FR-073); +- fingerprint deltas are disclosed on the report. + +### SLICE-024 — Verified Euclidean and CKA +Implemented: `euclidean_of_observations` and `cka_of_observation_sets` load tensors only after hash checks. + +### SLICE-025 — Replay envelope +Implemented: `replay_envelope` checks cosine, Euclidean, and artifact-hash equality against manifest thresholds. + +### SLICE-026 — Manifest/fixture classification consistency +Implemented: a fixture whose `data_classification` differs from the manifest becomes a `FailureRecord`. + +### SLICE-027 — Failure environment fingerprint +Implemented: capture failures record a Python environment fingerprint. + ## Remaining blockers 1. Freeze an operator-approved EXP-001 source corpus; Noesis does not invent that semantic corpus. diff --git a/specs/NOESIS-SYSTEM-SPEC.md b/specs/NOESIS-SYSTEM-SPEC.md index 16296fd..fa167ba 100644 --- a/specs/NOESIS-SYSTEM-SPEC.md +++ b/specs/NOESIS-SYSTEM-SPEC.md @@ -3,7 +3,7 @@ Status: `CANON-SHADOW` Version: `0.3.0` Specification state: `COMPLETE` -Runtime state: `SLICE-001 + SLICE-002A + SLICE-003 + SLICE-004 + SLICE-005 + SLICE-006 + SLICE-007 + SLICE-008 + SLICE-009 + SLICE-010 + SLICE-011 + SLICE-012 + SLICE-013 + SLICE-014 + SLICE-015 + SLICE-016 + SLICE-017 + SLICE-018 + SLICE-019 + SLICE-020 + SLICE-021 + SLICE-022 IMPLEMENTED` +Runtime state: `SLICE-001 + SLICE-002A + SLICE-003 + SLICE-004 + SLICE-005 + SLICE-006 + SLICE-007 + SLICE-008 + SLICE-009 + SLICE-010 + SLICE-011 + SLICE-012 + SLICE-013 + SLICE-014 + SLICE-015 + SLICE-016 + SLICE-017 + SLICE-018 + SLICE-019 + SLICE-020 + SLICE-021 + SLICE-022 + SLICE-023 + SLICE-024 + SLICE-025 + SLICE-026 + SLICE-027 IMPLEMENTED` This document is the canonical system overview. Normative details are decomposed into the referenced specifications below. diff --git a/src/noesis/capture/executor.py b/src/noesis/capture/executor.py index 795777e..50a7347 100644 --- a/src/noesis/capture/executor.py +++ b/src/noesis/capture/executor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import platform import re from dataclasses import dataclass from typing import Any, Iterable, Mapping @@ -7,6 +8,7 @@ from noesis.adapters.base import ModelAdapter from noesis.artifacts.store import ContentAddressedStore from noesis.capture.runner import CaptureRunner +from noesis.capture.fingerprint import environment_fingerprint from noesis.contracts.registry import ContractRegistry from noesis.domain.models import CaptureRequest, FailureRecord, InputFixture, RepresentationSite from noesis.security import authorize_scope, classify_fixture @@ -69,6 +71,8 @@ def execute( "access_scope": fixture.access_scope, } ) + if fixture.data_classification != manifest["data_classification"]: + raise ValueError("fixture data_classification must match the manifest") except ValueError as exc: failures.append( self._failure(manifest["experiment_id"], run_id, fixture_id, "classification", 0, exc) @@ -97,6 +101,9 @@ def _failure(self, experiment_id: str, run_id: str, fixture_id: str, site: str, error_type=type(exc).__name__, message=str(exc), ).as_dict() + environment = {"python": platform.python_version()} + environment["fingerprint"] = environment_fingerprint(environment) + record["environment"] = environment self.registry.validate("failure-record", record) self.store.put_json(record) return record diff --git a/src/noesis/metrics/compare.py b/src/noesis/metrics/compare.py index 598872e..811623a 100644 --- a/src/noesis/metrics/compare.py +++ b/src/noesis/metrics/compare.py @@ -1,18 +1,78 @@ from __future__ import annotations -from typing import Any, Mapping +from typing import Any, Mapping, Sequence + +import numpy as np from noesis.artifacts.store import ContentAddressedStore -from noesis.metrics.core import cosine_similarity +from noesis.metrics.core import cosine_similarity, euclidean_distance, linear_cka -def cosine_of_observations( +def _vectors( store: ContentAddressedStore, left: Mapping[str, Any], right: Mapping[str, Any], -) -> float: +): left_art = left["artifact"] right_art = right["artifact"] - left_vec = store.load_vector(left_art["uri"], left_art["sha256"], left_art["shape"]) - right_vec = store.load_vector(right_art["uri"], right_art["sha256"], right_art["shape"]) + return ( + store.load_vector(left_art["uri"], left_art["sha256"], left_art["shape"]), + store.load_vector(right_art["uri"], right_art["sha256"], right_art["shape"]), + ) + + +def cosine_of_observations( + store: ContentAddressedStore, + left: Mapping[str, Any], + right: Mapping[str, Any], +) -> float: + left_vec, right_vec = _vectors(store, left, right) return cosine_similarity(left_vec, right_vec) + + +def euclidean_of_observations( + store: ContentAddressedStore, + left: Mapping[str, Any], + right: Mapping[str, Any], +) -> float: + left_vec, right_vec = _vectors(store, left, right) + return euclidean_distance(left_vec, right_vec) + + +def cka_of_observation_sets( + store: ContentAddressedStore, + left: Sequence[Mapping[str, Any]], + right: Sequence[Mapping[str, Any]], +) -> float: + if len(left) != len(right) or len(left) < 2: + raise ValueError("CKA requires two aligned observation sets with at least two samples") + left_mat = np.stack( + [store.load_vector(item["artifact"]["uri"], item["artifact"]["sha256"], item["artifact"]["shape"]) for item in left] + ) + right_mat = np.stack( + [store.load_vector(item["artifact"]["uri"], item["artifact"]["sha256"], item["artifact"]["shape"]) for item in right] + ) + return linear_cka(left_mat, right_mat) + + +def replay_envelope( + store: ContentAddressedStore, + left: Mapping[str, Any], + right: Mapping[str, Any], + thresholds: Mapping[str, Any], +) -> dict[str, Any]: + cosine = cosine_of_observations(store, left, right) + distance = euclidean_of_observations(store, left, right) + checks: dict[str, bool] = {} + if "replay_cosine" in thresholds: + checks["replay_cosine"] = cosine + 1e-12 >= float(thresholds["replay_cosine"]) + if "replay_euclidean" in thresholds: + checks["replay_euclidean"] = distance - 1e-12 <= float(thresholds["replay_euclidean"]) + if thresholds.get("replay_artifact_hash_equal") is True: + checks["replay_artifact_hash_equal"] = left["artifact"]["sha256"] == right["artifact"]["sha256"] + return { + "cosine": cosine, + "euclidean": distance, + "checks": checks, + "within_thresholds": all(checks.values()) if checks else True, + } diff --git a/src/noesis/replication/engine.py b/src/noesis/replication/engine.py index ee967ca..38dbb05 100644 --- a/src/noesis/replication/engine.py +++ b/src/noesis/replication/engine.py @@ -2,8 +2,9 @@ import hashlib from dataclasses import dataclass -from typing import Any, Sequence +from typing import Any, Mapping, Sequence +from noesis.capture.fingerprint import compare_environment_fingerprints from noesis.settlement import EvidenceRef, SettlementRequest, settle_evidence @@ -19,6 +20,9 @@ class ReplicationRequest: tolerance: float environment_delta: Sequence[str] = () artifacts_resolvable: bool = True + original_environment: Mapping[str, Any] | None = None + replica_environment: Mapping[str, Any] | None = None + require_equivalent_environment: bool = False @dataclass(frozen=True, slots=True) @@ -28,6 +32,7 @@ class ReplicationReport: settlement: dict[str, Any] original_settlement_id: str environment_delta: tuple[str, ...] + fingerprint_report: dict[str, Any] | None = None def replicate_settlement(request: ReplicationRequest) -> ReplicationReport: @@ -54,9 +59,18 @@ def replicate_settlement(request: ReplicationRequest) -> ReplicationReport: independent = request.original_operator != request.replica_operator has_new_control = bool(request.new_control_ids) + fingerprint_report = None + equivalent_env = True + if request.original_environment is not None and request.replica_environment is not None: + fingerprint_report = compare_environment_fingerprints( + request.original_environment, request.replica_environment + ) + equivalent_env = fingerprint_report["status"] == "EQUIVALENT" if not request.artifacts_resolvable: classification = "NOT_COMPUTABLE" - elif within and independent and has_new_control: + elif within and independent and has_new_control and ( + equivalent_env or not request.require_equivalent_environment + ): classification = "REPLICATED" elif not within: classification = "FAILED_REPLICATION" @@ -78,6 +92,8 @@ def replicate_settlement(request: ReplicationRequest) -> ReplicationReport: limitations.append("replica operator is not independent of the original producer") if not has_new_control: limitations.append("replication did not introduce a new control or independent fixture") + if request.require_equivalent_environment and not equivalent_env: + limitations.append("replica environment fingerprint differs; new run identity required") requested = "INFERRED" independent_flag = False else: @@ -97,12 +113,14 @@ def replicate_settlement(request: ReplicationRequest) -> ReplicationReport: confidence_basis=(f"classification={classification}",), ) ) + extra_delta = tuple(fingerprint_report.get("delta", []) if fingerprint_report else ()) return ReplicationReport( classification=classification, envelope=envelope, settlement=settlement, original_settlement_id=str(original["settlement_id"]), - environment_delta=tuple(request.environment_delta), + environment_delta=tuple(request.environment_delta) + extra_delta, + fingerprint_report=fingerprint_report, ) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index c862968..36f6c98 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -1,7 +1,12 @@ import pytest from noesis.artifacts.store import ContentAddressedStore -from noesis.metrics.compare import cosine_of_observations +from noesis.metrics.compare import ( + cka_of_observation_sets, + cosine_of_observations, + euclidean_of_observations, + replay_envelope, +) from noesis.metrics.core import cosine_similarity @@ -44,3 +49,34 @@ def test_cosine_of_observations_refuses_substituted_artifact(tmp_path): with pytest.raises(RuntimeError, match="hash mismatch"): cosine_of_observations(store, left, right) assert cosine_similarity((1.0, 0.0), (0.0, 1.0)) == pytest.approx(0.0) + + +def _obs(store, vector): + sha, path = store.put_vector(vector) + return {"artifact": {"uri": path.as_uri(), "sha256": sha, "shape": [len(vector)]}} + + +def test_euclidean_and_cka_of_verified_observations(tmp_path): + store = ContentAddressedStore(tmp_path) + a = _obs(store, (1.0, 0.0)) + b = _obs(store, (0.0, 1.0)) + c = _obs(store, (1.0, 0.0)) + d = _obs(store, (0.0, 1.0)) + assert euclidean_of_observations(store, a, b) == pytest.approx(float(2**0.5)) + assert cka_of_observation_sets(store, (a, b), (c, d)) == pytest.approx(1.0) + + +def test_replay_envelope_respects_manifest_thresholds(tmp_path): + store = ContentAddressedStore(tmp_path) + left = _obs(store, (1.0, 0.0)) + same = _obs(store, (1.0, 0.0)) + other = _obs(store, (0.0, 1.0)) + ok = replay_envelope( + store, + left, + same, + {"replay_cosine": 1.0, "replay_euclidean": 0.0, "replay_artifact_hash_equal": True}, + ) + assert ok["within_thresholds"] is True + bad = replay_envelope(store, left, other, {"replay_cosine": 1.0}) + assert bad["within_thresholds"] is False diff --git a/tests/test_executor.py b/tests/test_executor.py index 7e3ceb8..11a28d9 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -54,6 +54,19 @@ def test_manifest_executor_emits_observation(tmp_path): assert report.failures == () +def test_manifest_executor_rejects_classification_mismatch(tmp_path): + adapter = DeterministicFakeAdapter(dimensions=8) + store = ContentAddressedStore(tmp_path) + executor = ManifestExecutor(adapter, store, ContractRegistry("contracts")) + report = executor.execute( + manifest(), + [InputFixture("fixture-001", "stable fixture", data_classification="RESEARCH_INTERNAL")], + "run-mismatch", + ) + assert report.observations == () + assert any("must match the manifest" in item["message"] for item in report.failures) + + def test_manifest_executor_rejects_secret_like_fixture_text(tmp_path): adapter = DeterministicFakeAdapter(dimensions=8) store = ContentAddressedStore(tmp_path) @@ -90,3 +103,4 @@ def test_manifest_executor_persists_missing_fixture_as_failure(tmp_path): assert len(report.failures) == 1 assert report.failures[0]["error_type"] == "KeyError" assert report.failures[0]["provenance"] == "OBSERVED" + assert report.failures[0]["environment"]["fingerprint"] diff --git a/tests/test_replication.py b/tests/test_replication.py index c9dfb1b..f3c8475 100644 --- a/tests/test_replication.py +++ b/tests/test_replication.py @@ -2,6 +2,7 @@ import hashlib +from noesis.capture.fingerprint import environment_fingerprint from noesis.contracts.registry import ContractRegistry from noesis.replication import ReplicationRequest, replicate_settlement from noesis.settlement import EvidenceRef, SettlementRequest, settle_evidence @@ -117,6 +118,32 @@ def test_same_operator_cannot_fully_replicate(): assert report.settlement["promotion"] != "ELIGIBLE_FOR_REVIEW" +def test_equivalent_environment_requirement_blocks_full_replication(): + original = _original() + original_env = {"python": "3.12.3", "adapter": "fake"} + replica_env = {"python": "3.13.0", "adapter": "fake"} + original_env = {**original_env, "fingerprint": environment_fingerprint(original_env)} + replica_env = {**replica_env, "fingerprint": environment_fingerprint(replica_env)} + report = replicate_settlement( + ReplicationRequest( + original_settlement=original, + original_metrics=(0.5,), + replica_metrics=(0.5,), + original_operator="operator:a", + replica_operator="operator:b", + new_control_ids=("ctrl-new-1",), + replica_settlement_id="set-rep-env", + tolerance=0.05, + original_environment=original_env, + replica_environment=replica_env, + require_equivalent_environment=True, + ) + ) + assert report.classification == "PARTIAL" + assert report.fingerprint_report["status"] == "NEW_RUN_REQUIRED" + assert "python" in report.environment_delta + + def test_missing_new_control_is_partial(): original = _original() report = replicate_settlement(