rmic-guard 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: rmic-guard
3
+ Version: 0.1.0
4
+ Summary: Role-boundary preservation and identity-drift detection for autonomous LLM agents.
5
+ Author-email: Arshitha S <arshitha.s1104@gmail.com>, Charithra H S <charithrahs@gmail.com>, Chandrakala <chandanah1605@gmail.com>, Neethu B Krishna <Neethu.b.krishna10@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Arshu-1104/RMIC
8
+ Project-URL: Repository, https://github.com/Arshu-1104/RMIC
9
+ Keywords: llm,agents,safety,guardrails,prompt-injection,drift-detection
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: litellm>=1.56.0
21
+ Requires-Dist: numpy>=1.26.0
22
+ Requires-Dist: scipy>=1.13.0
23
+ Requires-Dist: cryptography>=42.0.0
24
+ Requires-Dist: PyYAML>=6.0.0
25
+ Requires-Dist: fastembed>=0.2.0
26
+ Provides-Extra: torch
27
+ Requires-Dist: sentence-transformers>=3.0.0; extra == "torch"
28
+
29
+ # rmic-guard
30
+
31
+ Role-boundary preservation and identity-drift detection for autonomous LLM agents.
32
+
33
+ `rmic-guard` gives an agent a **contract** — an explicit statement of its role,
34
+ allowed/forbidden tools, and data-access scope — and enforces it at runtime
35
+ with two layers:
36
+
37
+ - **Hard rules**: exact-match checks on tool names, parameter bounds, and data scope.
38
+ - **IDS (Identity Drift Score)**: an embedding-based signal (role distance,
39
+ semantic grounding, trajectory curvature) that flags gradual drift a
40
+ hard-rule check alone would miss.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install rmic-guard
46
+ ```
47
+
48
+ If your platform can't run `fastembed`'s ONNX backend, install the
49
+ PyTorch-based fallback instead:
50
+
51
+ ```bash
52
+ pip install "rmic-guard[torch]"
53
+ ```
54
+
55
+ ## Quickstart
56
+
57
+ ```python
58
+ from rmic_guard import load_contract, EnforcementEngine, ClaudeReasoning
59
+
60
+ # 1. Load a role contract (see contracts/*.json in the repo for examples)
61
+ contract = load_contract("contracts/financial_agent.json")
62
+
63
+ # 2. Plan a tool call from a user message
64
+ reasoning = ClaudeReasoning() # reads ANTHROPIC_API_KEY from the environment
65
+ plan = reasoning.plan_tool_call(
66
+ "What's my current account balance?",
67
+ contract=contract,
68
+ condition="C",
69
+ )
70
+
71
+ # 3. Enforce the contract before executing
72
+ engine = EnforcementEngine(contract=contract, tools=my_tool_registry, ledger=None)
73
+ outcome = engine.evaluate_and_maybe_execute(
74
+ plan,
75
+ recent_ids=[], # pass prior IDS scores for trajectory tracking across a session
76
+ drift_type=None,
77
+ execute_tool=True,
78
+ enforcement_mode="full", # "full" | "hard_rules_only" | "ids_only"
79
+ )
80
+
81
+ print(outcome.decision) # "PASS" | "WARN" | "BLOCK" | "NEEDS_RECOVERY" | "PREEMPTIVE_WARN"
82
+ ```
83
+
84
+ Groq (Llama/Mixtral) is supported as a drop-in alternative to Claude:
85
+
86
+ ```python
87
+ from rmic_guard import GroqReasoning
88
+ reasoning = GroqReasoning(model_name="groq/llama-3.3-70b-versatile")
89
+ ```
90
+
91
+ ## Writing a contract
92
+
93
+ A contract is a JSON file describing the agent's role, its allowed and
94
+ forbidden tools, parameter constraints, and data-access scope. See the
95
+ `contracts/` directory in the [GitHub repo](https://github.com/Charithra207/RMIC-guard)
96
+ for real examples across financial, support, healthcare-research, and
97
+ legal-review agent roles.
98
+
99
+ ## Status
100
+
101
+ This is an early (0.1.x) release from an active research project on
102
+ role-boundary preservation in autonomous agents. APIs may change between
103
+ minor versions until 1.0. Issues and contributions welcome on GitHub.
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,79 @@
1
+ # rmic-guard
2
+
3
+ Role-boundary preservation and identity-drift detection for autonomous LLM agents.
4
+
5
+ `rmic-guard` gives an agent a **contract** — an explicit statement of its role,
6
+ allowed/forbidden tools, and data-access scope — and enforces it at runtime
7
+ with two layers:
8
+
9
+ - **Hard rules**: exact-match checks on tool names, parameter bounds, and data scope.
10
+ - **IDS (Identity Drift Score)**: an embedding-based signal (role distance,
11
+ semantic grounding, trajectory curvature) that flags gradual drift a
12
+ hard-rule check alone would miss.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install rmic-guard
18
+ ```
19
+
20
+ If your platform can't run `fastembed`'s ONNX backend, install the
21
+ PyTorch-based fallback instead:
22
+
23
+ ```bash
24
+ pip install "rmic-guard[torch]"
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ ```python
30
+ from rmic_guard import load_contract, EnforcementEngine, ClaudeReasoning
31
+
32
+ # 1. Load a role contract (see contracts/*.json in the repo for examples)
33
+ contract = load_contract("contracts/financial_agent.json")
34
+
35
+ # 2. Plan a tool call from a user message
36
+ reasoning = ClaudeReasoning() # reads ANTHROPIC_API_KEY from the environment
37
+ plan = reasoning.plan_tool_call(
38
+ "What's my current account balance?",
39
+ contract=contract,
40
+ condition="C",
41
+ )
42
+
43
+ # 3. Enforce the contract before executing
44
+ engine = EnforcementEngine(contract=contract, tools=my_tool_registry, ledger=None)
45
+ outcome = engine.evaluate_and_maybe_execute(
46
+ plan,
47
+ recent_ids=[], # pass prior IDS scores for trajectory tracking across a session
48
+ drift_type=None,
49
+ execute_tool=True,
50
+ enforcement_mode="full", # "full" | "hard_rules_only" | "ids_only"
51
+ )
52
+
53
+ print(outcome.decision) # "PASS" | "WARN" | "BLOCK" | "NEEDS_RECOVERY" | "PREEMPTIVE_WARN"
54
+ ```
55
+
56
+ Groq (Llama/Mixtral) is supported as a drop-in alternative to Claude:
57
+
58
+ ```python
59
+ from rmic_guard import GroqReasoning
60
+ reasoning = GroqReasoning(model_name="groq/llama-3.3-70b-versatile")
61
+ ```
62
+
63
+ ## Writing a contract
64
+
65
+ A contract is a JSON file describing the agent's role, its allowed and
66
+ forbidden tools, parameter constraints, and data-access scope. See the
67
+ `contracts/` directory in the [GitHub repo](https://github.com/Charithra207/RMIC-guard)
68
+ for real examples across financial, support, healthcare-research, and
69
+ legal-review agent roles.
70
+
71
+ ## Status
72
+
73
+ This is an early (0.1.x) release from an active research project on
74
+ role-boundary preservation in autonomous agents. APIs may change between
75
+ minor versions until 1.0. Issues and contributions welcome on GitHub.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,160 @@
1
+ # RMIC-Guard
2
+
3
+ **Role-Model Identity Contract Guard** — research framework proving that enforcement
4
+ position (external middleware vs. in-prompt self-policing) is the primary determinant
5
+ of role identity drift suppression in autonomous LLM agents.
6
+
7
+ ---
8
+
9
+ ## Prerequisites
10
+
11
+ - Python 3.11 (Windows recommended)
12
+ - Anthropic API key (`sk-ant-...`) with credits
13
+
14
+ ---
15
+
16
+ ## Setup
17
+
18
+ ```powershell
19
+ py -3.11 -m venv .venv
20
+ .\.venv\Scripts\Activate.ps1
21
+ pip install -r requirements.txt
22
+ ```
23
+
24
+ Create `.env` in the project root:
25
+
26
+ ```
27
+ ANTHROPIC_API_KEY=your_key_here
28
+ ANTHROPIC_MODEL=claude-sonnet-4-6
29
+ GEMINI_API_KEY=your_key_here
30
+ GROQ_API_KEY=your_key_here
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Validate
36
+
37
+ ```powershell
38
+ python setup.py
39
+ ```
40
+
41
+ Expected: 6/6 checks pass.
42
+
43
+ ---
44
+
45
+ ## Run Experiment
46
+
47
+ **Test one provider first** (60 API calls):
48
+
49
+ ```powershell
50
+ python -m experiment.runner --test --provider anthropic
51
+ ```
52
+
53
+ Expected output: `rows_inserted=60`
54
+
55
+ Test Gemini only:
56
+
57
+ ```powershell
58
+ python -m experiment.runner --test --provider gemini
59
+ ```
60
+
61
+ Test Groq only:
62
+
63
+ ```powershell
64
+ python -m experiment.runner --test --provider groq
65
+ ```
66
+
67
+ Run multi-model comparison (all available providers with keys):
68
+
69
+ ```powershell
70
+ python -m experiment.runner --test --multi-model --compare-providers
71
+ ```
72
+
73
+ **Full run** (1,300 API calls per run, ~$8–15 at Sonnet 4.6 pricing):
74
+
75
+ ```powershell
76
+ python -m experiment.runner
77
+ ```
78
+
79
+ Run 3 times for statistical variance.
80
+
81
+ ---
82
+
83
+ ## Statistical Report
84
+
85
+ ```powershell
86
+ python -m experiment.statistical_tests
87
+ ```
88
+
89
+ Requires 2+ full runs. Report saved to `results/statistical_report.txt`.
90
+
91
+ ---
92
+
93
+ ## Dashboard
94
+
95
+ ```powershell
96
+ python -m uvicorn dashboard.app:app --reload --port 8001
97
+ ```
98
+
99
+ Open `http://127.0.0.1:8001` — use the run selector to switch between runs.
100
+
101
+ ---
102
+
103
+ ## Experimental Design
104
+
105
+ **4 Roles:** `financial_agent`, `support_agent`, `healthcare_research_agent`, `legal_review_agent`
106
+
107
+ **5 Conditions:**
108
+
109
+ | Condition | Description |
110
+ |---|---|
111
+ | `A_no_contract` | Baseline — no contract, no enforcement |
112
+ | `B_prompt_contract` | Contract in system prompt — LLM self-polices |
113
+ | `C_rmic_middleware` | External middleware — full dual-pass enforcement |
114
+ | `C1_hard_rules_only` | Ablation — hard rules only, no IDS |
115
+ | `C2_ids_only` | Ablation — semantic IDS only, no hard rules |
116
+
117
+ **65 prompts per role per condition:** 50 adversarial + 10 generic legitimate + 5 role-specific legitimate
118
+
119
+ ---
120
+
121
+ ## IDS Metrics (7 Independent Signals)
122
+
123
+ | Metric | Description |
124
+ |---|---|
125
+ | Base IDS | 0.4 × RoleDistance + 0.4 × SemanticGrounding + 0.2 × TrajectoryCurvature |
126
+ | Mahalanobis | Covariance-aware distance in 384-dim embedding space |
127
+ | KL Divergence | Asymmetric early-warning divergence signal |
128
+ | Jensen–Shannon | Primary enforcement metric — symmetric, bounded [0, 1] |
129
+ | Wasserstein | Geometry-aware Earth Mover's Distance |
130
+ | Hellinger | Tail-sensitive, bounded [0, 1] |
131
+ | Tool Frequency | Behavioral pattern drift across session window |
132
+
133
+ All 7 computed independently. Never blended.
134
+
135
+ ---
136
+
137
+ ## Key Metrics
138
+
139
+ | Metric | Definition |
140
+ |---|---|
141
+ | DSR | Drift Suppression Rate = blocked / expected_drift |
142
+ | DDR | Drift Detection Rate = detected / expected_drift |
143
+ | FPR | False Positive Rate = false_detections / legitimate |
144
+
145
+ ---
146
+
147
+ ## Exports
148
+
149
+ Each full run auto-exports to `results/exports/{run_id}.csv` (1,300 rows, 22 columns).
150
+
151
+ ---
152
+
153
+ ## Team Branches
154
+
155
+ | Branch | Scope |
156
+ |---|---|
157
+ | `charithra/core` | Core engine |
158
+ | `chandrakala/experiment` | Experiment pipeline |
159
+ | `arshitha/dashboard` | Dashboard |
160
+ | `neethu/contracts` | Contracts and prompts |
File without changes
@@ -0,0 +1,116 @@
1
+ """Append-only audit ledger with per-entry Ed25519 signatures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from dataclasses import asdict, dataclass
8
+ from datetime import datetime, timezone
9
+ from hashlib import sha256
10
+ from pathlib import Path
11
+ from typing import Any, Mapping
12
+
13
+ from cryptography.hazmat.primitives import serialization
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
15
+
16
+ __all__ = ["AuditEntry", "AuditLedger"]
17
+
18
+
19
+ def _utc_iso() -> str:
20
+ return datetime.now(timezone.utc).isoformat()
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AuditEntry:
25
+ timestamp: str
26
+ agent_id: str
27
+ input_hash: str
28
+ ids_score: float
29
+ drift_type: str | None
30
+ drift_velocity: float | None
31
+ decision: str
32
+ contract_hash: str
33
+ recovery_attempted: bool
34
+ role_distance: float | None = None
35
+ semantic_grounding: float | None = None
36
+ trajectory_curvature: float | None = None
37
+ failure_reason: str | None = None
38
+ false_positive: bool = False
39
+ false_negative: bool = False
40
+
41
+
42
+ class AuditLedger:
43
+ """
44
+ Append-only JSONL ledger. Each line is a JSON object:
45
+ { "entry": {...}, "signature": "<base64>" }
46
+ """
47
+
48
+ def __init__(self, path: str | Path, private_key: Ed25519PrivateKey | None = None) -> None:
49
+ self.path = Path(path)
50
+ self._private_key = private_key or Ed25519PrivateKey.generate()
51
+ self._public_key = self._private_key.public_key()
52
+
53
+ @property
54
+ def public_key(self) -> Ed25519PublicKey:
55
+ return self._public_key
56
+
57
+ def public_key_bytes(self) -> bytes:
58
+ return self._public_key.public_bytes(
59
+ encoding=serialization.Encoding.Raw,
60
+ format=serialization.PublicFormat.Raw,
61
+ )
62
+
63
+ def append(self, entry: AuditEntry) -> None:
64
+ payload = json.dumps(asdict(entry), sort_keys=True, separators=(",", ":")).encode("utf-8")
65
+ sig = self._private_key.sign(payload)
66
+ line = json.dumps(
67
+ {
68
+ "entry": asdict(entry),
69
+ "signature": base64.b64encode(sig).decode("ascii"),
70
+ },
71
+ separators=(",", ":"),
72
+ )
73
+ self.path.parent.mkdir(parents=True, exist_ok=True)
74
+ with self.path.open("a", encoding="utf-8") as f:
75
+ f.write(line + "\n")
76
+
77
+ @staticmethod
78
+ def verify_line(line: str, public_key: Ed25519PublicKey) -> bool:
79
+ obj = json.loads(line)
80
+ entry = obj["entry"]
81
+ sig = base64.b64decode(obj["signature"])
82
+ payload = json.dumps(entry, sort_keys=True, separators=(",", ":")).encode("utf-8")
83
+ try:
84
+ public_key.verify(sig, payload)
85
+ except Exception:
86
+ return False
87
+ return True
88
+
89
+
90
+ def hash_text(text: str) -> str:
91
+ """SHA-256 hex digest of UTF-8 text (for input_hash)."""
92
+ return sha256(text.encode("utf-8")).hexdigest()
93
+
94
+
95
+ def entry_from_mapping(m: Mapping[str, Any]) -> AuditEntry:
96
+ return AuditEntry(
97
+ timestamp=str(m["timestamp"]),
98
+ agent_id=str(m["agent_id"]),
99
+ input_hash=str(m["input_hash"]),
100
+ ids_score=float(m["ids_score"]),
101
+ role_distance=None if m.get("role_distance") is None else float(m["role_distance"]),
102
+ semantic_grounding=(
103
+ None if m.get("semantic_grounding") is None else float(m["semantic_grounding"])
104
+ ),
105
+ trajectory_curvature=(
106
+ None if m.get("trajectory_curvature") is None else float(m["trajectory_curvature"])
107
+ ),
108
+ drift_type=m.get("drift_type"),
109
+ drift_velocity=None if m.get("drift_velocity") is None else float(m["drift_velocity"]),
110
+ decision=str(m["decision"]),
111
+ failure_reason=(None if m.get("failure_reason") is None else str(m.get("failure_reason"))),
112
+ false_positive=bool(m.get("false_positive", False)),
113
+ false_negative=bool(m.get("false_negative", False)),
114
+ contract_hash=str(m["contract_hash"]),
115
+ recovery_attempted=bool(m["recovery_attempted"]),
116
+ )
@@ -0,0 +1,218 @@
1
+ """Load, cryptographically seal, and verify RMIC identity contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+ from utils.config import load_config
12
+
13
+ __all__ = [
14
+ "DataScope",
15
+ "ParameterConstraint",
16
+ "RMICContract",
17
+ "canonical_contract_dict_for_hash",
18
+ "compute_contract_hash",
19
+ "load_contract",
20
+ "seal_contract_file",
21
+ ]
22
+
23
+
24
+ def canonical_contract_dict_for_hash(data: Mapping[str, Any]) -> dict[str, Any]:
25
+ """Return a JSON-serialisable dict for hashing (excludes contract_hash only)."""
26
+ return {k: v for k, v in sorted(data.items()) if k != "contract_hash"}
27
+
28
+
29
+ def compute_contract_hash(data: Mapping[str, Any]) -> str:
30
+ """SHA-256 over stable JSON of all fields except contract_hash."""
31
+ payload = canonical_contract_dict_for_hash(dict(data))
32
+ blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
33
+ return hashlib.sha256(blob).hexdigest()
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class DataScope:
38
+ """Immutable data-scope slice from the contract JSON."""
39
+
40
+ accessible: tuple[str, ...]
41
+ prohibited: tuple[str, ...]
42
+ pii_categories: tuple[str, ...]
43
+
44
+ @staticmethod
45
+ def from_mapping(m: Mapping[str, Any]) -> DataScope:
46
+ return DataScope(
47
+ accessible=tuple(m.get("accessible") or ()),
48
+ prohibited=tuple(m.get("prohibited") or ()),
49
+ pii_categories=tuple(m.get("pii_categories") or ()),
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ParameterConstraint:
55
+ """Single parameter bound as loaded from the contract."""
56
+
57
+ name: str
58
+ max: float | int | None
59
+ min: float | int | None
60
+ value_type: str
61
+
62
+ @staticmethod
63
+ def from_entry(name: str, spec: Mapping[str, Any]) -> ParameterConstraint:
64
+ return ParameterConstraint(
65
+ name=name,
66
+ max=spec.get("max"),
67
+ min=spec.get("min"),
68
+ value_type=str(spec.get("type", "float")),
69
+ )
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class RMICContract:
74
+ """Frozen runtime identity contract. No field may change after construction."""
75
+
76
+ agent_id: str
77
+ role_name: str
78
+ sector: str
79
+ role_description: str
80
+ semantic_anchors: tuple[str, ...]
81
+ allowed_actions: tuple[str, ...]
82
+ forbidden_actions: tuple[str, ...]
83
+ data_scope: DataScope
84
+ parameter_constraints: tuple[ParameterConstraint, ...]
85
+ ids_warn_threshold: float
86
+ ids_block_threshold: float
87
+ drift_velocity_threshold: float
88
+ recovery_policy: str
89
+ compliance_tags: tuple[str, ...]
90
+ contract_version: str
91
+ created_at: str | None
92
+ contract_hash: str
93
+ anchor_embedding: tuple[float, ...]
94
+
95
+ def constraints_by_name(self) -> dict[str, ParameterConstraint]:
96
+ return {c.name: c for c in self.parameter_constraints}
97
+
98
+
99
+ def _as_tuple_str(v: Any) -> tuple[str, ...]:
100
+ if v is None:
101
+ return ()
102
+ if isinstance(v, str):
103
+ return (v,)
104
+ return tuple(str(x) for x in v)
105
+
106
+
107
+ def _contract_from_dict(data: dict[str, Any], *, require_hash_match: bool) -> RMICContract:
108
+ cfg = load_config()
109
+ tcfg = cfg.get("thresholds", {})
110
+ stored_hash = data.get("contract_hash")
111
+ if require_hash_match:
112
+ if not stored_hash:
113
+ raise ValueError("contract_hash missing; seal the contract before load with verify")
114
+ computed = compute_contract_hash(data)
115
+ if computed != stored_hash:
116
+ raise ValueError("contract_hash mismatch — contract was tampered with or corrupted")
117
+
118
+ ds_raw = data.get("data_scope") or {}
119
+ if not isinstance(ds_raw, dict):
120
+ raise TypeError("data_scope must be an object")
121
+
122
+ pc_raw = data.get("parameter_constraints") or {}
123
+ if not isinstance(pc_raw, dict):
124
+ raise TypeError("parameter_constraints must be an object")
125
+
126
+ constraints: list[ParameterConstraint] = []
127
+ for name in sorted(pc_raw.keys()):
128
+ spec = pc_raw[name]
129
+ if not isinstance(spec, dict):
130
+ raise TypeError(f"parameter_constraints.{name} must be an object")
131
+ constraints.append(ParameterConstraint.from_entry(name, spec))
132
+
133
+ anchors = _as_tuple_str(data.get("semantic_anchors"))
134
+ if not anchors:
135
+ raise ValueError("semantic_anchors must contain at least one sentence")
136
+
137
+ emb = data.get("anchor_embedding")
138
+ if emb is None:
139
+ anchor_embedding: tuple[float, ...] = ()
140
+ else:
141
+ if not isinstance(emb, list):
142
+ raise TypeError("anchor_embedding must be a list of floats (seal the contract first)")
143
+ anchor_embedding = tuple(float(x) for x in emb)
144
+
145
+ return RMICContract(
146
+ agent_id=str(data["agent_id"]),
147
+ role_name=str(data["role_name"]),
148
+ sector=str(data["sector"]),
149
+ role_description=str(data.get("role_description", "")),
150
+ semantic_anchors=anchors,
151
+ allowed_actions=_as_tuple_str(data.get("allowed_actions")),
152
+ forbidden_actions=_as_tuple_str(data.get("forbidden_actions")),
153
+ data_scope=DataScope.from_mapping(ds_raw),
154
+ parameter_constraints=tuple(constraints),
155
+ ids_warn_threshold=float(
156
+ data.get("ids_warn_threshold", data.get("warn_threshold", tcfg.get("warn_threshold", 0.35)))
157
+ ),
158
+ ids_block_threshold=float(
159
+ data.get("ids_block_threshold", data.get("block_threshold", tcfg.get("block_threshold", 0.60)))
160
+ ),
161
+ drift_velocity_threshold=float(
162
+ data.get(
163
+ "drift_velocity_threshold",
164
+ data.get("velocity_threshold", tcfg.get("velocity_threshold", 0.05)),
165
+ )
166
+ ),
167
+ recovery_policy=str(data.get("recovery_policy", "re-anchor")),
168
+ compliance_tags=_as_tuple_str(data.get("compliance_tags")),
169
+ contract_version=str(data.get("contract_version", "1.0.0")),
170
+ created_at=data.get("created_at"),
171
+ contract_hash=str(stored_hash or compute_contract_hash(data)),
172
+ anchor_embedding=anchor_embedding,
173
+ )
174
+
175
+
176
+ def load_contract(path: str | Path, *, verify_hash: bool = True) -> RMICContract:
177
+ """Load a contract JSON file and optionally verify SHA-256 integrity."""
178
+ p = Path(path)
179
+ raw = json.loads(p.read_text(encoding="utf-8"))
180
+ if not isinstance(raw, dict):
181
+ raise TypeError("contract file must contain a JSON object")
182
+ return _contract_from_dict(raw, require_hash_match=verify_hash)
183
+
184
+
185
+ def seal_contract_file(
186
+ path: str | Path,
187
+ *,
188
+ write_back: bool = True,
189
+ model_name: str | None = None,
190
+ ) -> RMICContract:
191
+ """
192
+ Compute anchor_embedding (once) and contract_hash, optionally persist to disk.
193
+
194
+ anchor_embedding is the L2-normalised mean embedding of semantic_anchors.
195
+ """
196
+ from core.embedder import anchor_centroid_from_anchors
197
+
198
+ p = Path(path)
199
+ data = json.loads(p.read_text(encoding="utf-8"))
200
+ if not isinstance(data, dict):
201
+ raise TypeError("contract file must contain a JSON object")
202
+
203
+ anchors = _as_tuple_str(data.get("semantic_anchors"))
204
+ if not anchors:
205
+ raise ValueError("semantic_anchors must contain at least one sentence")
206
+
207
+ centroid = anchor_centroid_from_anchors(list(anchors), model_name=model_name)
208
+ data["anchor_embedding"] = [float(x) for x in centroid.tolist()]
209
+
210
+ if data.get("created_at") in (None, ""):
211
+ data["created_at"] = datetime.now(timezone.utc).isoformat()
212
+
213
+ data["contract_hash"] = compute_contract_hash(data)
214
+
215
+ if write_back:
216
+ p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
217
+
218
+ return _contract_from_dict(data, require_hash_match=True)