matrixai-core 1.0.0__py3-none-any.whl

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.
Files changed (133) hide show
  1. matrixai/__init__.py +6 -0
  2. matrixai/__main__.py +6 -0
  3. matrixai/actions/__init__.py +105 -0
  4. matrixai/actions/approval.py +176 -0
  5. matrixai/actions/composite_integration.py +164 -0
  6. matrixai/actions/contract.py +131 -0
  7. matrixai/actions/dryrun.py +187 -0
  8. matrixai/actions/executor.py +271 -0
  9. matrixai/actions/parser.py +312 -0
  10. matrixai/actions/registry.py +98 -0
  11. matrixai/actions/rollback.py +135 -0
  12. matrixai/actions/sandbox.py +190 -0
  13. matrixai/actions/schema.py +90 -0
  14. matrixai/actions/trace.py +105 -0
  15. matrixai/agents/__init__.py +62 -0
  16. matrixai/agents/architect.py +639 -0
  17. matrixai/agents/auditor.py +64 -0
  18. matrixai/agents/llm_adapters.py +301 -0
  19. matrixai/agents/llm_proposal.py +566 -0
  20. matrixai/agents/mathematical.py +477 -0
  21. matrixai/agents/optimizer.py +205 -0
  22. matrixai/agents/planner_verifier.py +227 -0
  23. matrixai/agents/prompt.py +541 -0
  24. matrixai/agents/prompt_supervisor.py +310 -0
  25. matrixai/agents/refinement.py +474 -0
  26. matrixai/agents/safety.py +12 -0
  27. matrixai/agents/verifier.py +99 -0
  28. matrixai/cli.py +3487 -0
  29. matrixai/compiler/__init__.py +24 -0
  30. matrixai/compiler/backend_contract.py +1431 -0
  31. matrixai/compiler/differentiable_python.py +599 -0
  32. matrixai/compiler/python_backend.py +418 -0
  33. matrixai/compiler/torch_forward.py +470 -0
  34. matrixai/continual/__init__.py +108 -0
  35. matrixai/continual/approval.py +544 -0
  36. matrixai/continual/collector.py +282 -0
  37. matrixai/continual/dataset.py +140 -0
  38. matrixai/continual/drift.py +482 -0
  39. matrixai/continual/monitor.py +231 -0
  40. matrixai/continual/parser.py +745 -0
  41. matrixai/continual/policy_view.py +80 -0
  42. matrixai/continual/refinement_bridge.py +86 -0
  43. matrixai/continual/rollback.py +322 -0
  44. matrixai/continual/trainer.py +615 -0
  45. matrixai/continual/versioning.py +352 -0
  46. matrixai/core/__init__.py +62 -0
  47. matrixai/core/ast_nodes.py +159 -0
  48. matrixai/core/evaluator.py +202 -0
  49. matrixai/core/graph.py +115 -0
  50. matrixai/core/lexer.py +88 -0
  51. matrixai/core/parser.py +219 -0
  52. matrixai/core/registry.py +42 -0
  53. matrixai/core/trace.py +79 -0
  54. matrixai/errors.py +196 -0
  55. matrixai/export/__init__.py +45 -0
  56. matrixai/export/bundle.py +327 -0
  57. matrixai/export/equivalence.py +334 -0
  58. matrixai/export/onnx_exporter.py +630 -0
  59. matrixai/export/wasm_exporter.py +256 -0
  60. matrixai/forward/__init__.py +48 -0
  61. matrixai/forward/composite_forward.py +290 -0
  62. matrixai/forward/composite_torch.py +399 -0
  63. matrixai/forward/dense_forward.py +99 -0
  64. matrixai/forward/dense_torch.py +159 -0
  65. matrixai/functions/__init__.py +117 -0
  66. matrixai/functions/math_ops.py +70 -0
  67. matrixai/functions/scoring.py +93 -0
  68. matrixai/functions/transforms.py +50 -0
  69. matrixai/ir/__init__.py +100 -0
  70. matrixai/ir/continual.py +282 -0
  71. matrixai/ir/expr.py +432 -0
  72. matrixai/ir/schema.py +437 -0
  73. matrixai/pack.py +223 -0
  74. matrixai/parameters/__init__.py +58 -0
  75. matrixai/parameters/network_params.py +459 -0
  76. matrixai/parameters/store.py +351 -0
  77. matrixai/parameters/tensor_bridge.py +242 -0
  78. matrixai/parser/__init__.py +6 -0
  79. matrixai/parser/parser.py +1042 -0
  80. matrixai/playground.py +4765 -0
  81. matrixai/playground_api.py +100 -0
  82. matrixai/registry/__init__.py +53 -0
  83. matrixai/registry/composite_hash.py +99 -0
  84. matrixai/registry/entry_hash.py +57 -0
  85. matrixai/registry/layout.py +58 -0
  86. matrixai/registry/model_registry.py +324 -0
  87. matrixai/registry/resolver.py +85 -0
  88. matrixai/registry/schema.py +82 -0
  89. matrixai/registry/signing.py +93 -0
  90. matrixai/runtime/__init__.py +6 -0
  91. matrixai/runtime/runtime.py +429 -0
  92. matrixai/sandbox.py +134 -0
  93. matrixai/scaffolding.py +102 -0
  94. matrixai/server.py +1490 -0
  95. matrixai/signing/__init__.py +6 -0
  96. matrixai/signing/keystore.py +229 -0
  97. matrixai/templates/classification/README.md +43 -0
  98. matrixai/templates/classification/dataset/test.csv +6 -0
  99. matrixai/templates/classification/dataset/train.csv +16 -0
  100. matrixai/templates/classification/input/sample.json +5 -0
  101. matrixai/templates/classification/models/best/params.json +22 -0
  102. matrixai/templates/classification/{{project_name}}.mxai +23 -0
  103. matrixai/templates/classification/{{project_name}}.mxtrain +32 -0
  104. matrixai/tooling.py +501 -0
  105. matrixai/training/__init__.py +162 -0
  106. matrixai/training/categorical.py +256 -0
  107. matrixai/training/composite_backprop.py +404 -0
  108. matrixai/training/composite_evaluator.py +132 -0
  109. matrixai/training/composite_generator.py +297 -0
  110. matrixai/training/composite_trainer.py +259 -0
  111. matrixai/training/data.py +281 -0
  112. matrixai/training/dataset_manifest.py +523 -0
  113. matrixai/training/dense_backprop.py +213 -0
  114. matrixai/training/dense_evaluator.py +265 -0
  115. matrixai/training/dense_generator.py +459 -0
  116. matrixai/training/dense_trainer.py +328 -0
  117. matrixai/training/differentiability.py +166 -0
  118. matrixai/training/generator.py +394 -0
  119. matrixai/training/parser.py +396 -0
  120. matrixai/training/spec.py +267 -0
  121. matrixai/training/supervised_prompt.py +406 -0
  122. matrixai/training/synthetic.py +322 -0
  123. matrixai/training/torch_evaluator.py +173 -0
  124. matrixai/training/torch_trainer.py +290 -0
  125. matrixai/training/trainer.py +1294 -0
  126. matrixai/training/verifier.py +346 -0
  127. matrixai/types.py +1174 -0
  128. matrixai_core-1.0.0.dist-info/METADATA +936 -0
  129. matrixai_core-1.0.0.dist-info/RECORD +133 -0
  130. matrixai_core-1.0.0.dist-info/WHEEL +5 -0
  131. matrixai_core-1.0.0.dist-info/entry_points.txt +2 -0
  132. matrixai_core-1.0.0.dist-info/licenses/LICENSE +661 -0
  133. matrixai_core-1.0.0.dist-info/top_level.txt +1 -0
matrixai/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Roberto Llamosas Conde
3
+
4
+ """MatrixAI MVP package."""
5
+
6
+ __version__ = "1.0.0"
matrixai/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Roberto Llamosas Conde
3
+
4
+ from matrixai.cli import main
5
+
6
+ raise SystemExit(main())
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Roberto Llamosas Conde
3
+
4
+ from matrixai.actions.schema import (
5
+ ActionContractSpec,
6
+ RollbackSpec,
7
+ RateLimitSpec,
8
+ SandboxLimitsSpec,
9
+ CAPABILITIES,
10
+ HIGH_RISK_CAPABILITIES,
11
+ MUTATING_CAPABILITIES,
12
+ )
13
+ from matrixai.actions.parser import MxactParseError, parse_mxact
14
+ from matrixai.actions.registry import (
15
+ CapabilityRegistry,
16
+ REQUIRED_SCOPE_FIELDS,
17
+ ScopeValidationResult,
18
+ registry,
19
+ )
20
+ from matrixai.actions.dryrun import (
21
+ DryRunReport,
22
+ DryRunSimulator,
23
+ RateTracker,
24
+ )
25
+ from matrixai.actions.executor import (
26
+ ActionExecutor,
27
+ ActionExecutorError,
28
+ ActionResult,
29
+ ExecutionContext,
30
+ )
31
+ from matrixai.actions.sandbox import (
32
+ SandboxedActionExecutor,
33
+ SandboxedExecutorError,
34
+ SandboxParams,
35
+ SandboxResult,
36
+ )
37
+ from matrixai.actions.trace import (
38
+ ActionTrace,
39
+ build_action_trace,
40
+ sign_action_trace,
41
+ verify_action_trace,
42
+ )
43
+ from matrixai.actions.rollback import (
44
+ RollbackManager,
45
+ RollbackResult,
46
+ RollbackError,
47
+ )
48
+ from matrixai.actions.approval import (
49
+ ApprovalStore,
50
+ ApprovalError,
51
+ HumanApprovalGate,
52
+ PendingExecution,
53
+ )
54
+ from matrixai.actions.contract import (
55
+ ActionContractValidationResult,
56
+ canonical_dict,
57
+ check_signing_key_available,
58
+ compute_action_contract_hash,
59
+ require_signing_key,
60
+ validate_action_contract,
61
+ )
62
+
63
+ __all__ = [
64
+ "ActionContractSpec",
65
+ "ActionContractValidationResult",
66
+ "MxactParseError",
67
+ "RollbackSpec",
68
+ "RateLimitSpec",
69
+ "SandboxLimitsSpec",
70
+ "CAPABILITIES",
71
+ "HIGH_RISK_CAPABILITIES",
72
+ "MUTATING_CAPABILITIES",
73
+ "canonical_dict",
74
+ "check_signing_key_available",
75
+ "compute_action_contract_hash",
76
+ "parse_mxact",
77
+ "require_signing_key",
78
+ "validate_action_contract",
79
+ "CapabilityRegistry",
80
+ "REQUIRED_SCOPE_FIELDS",
81
+ "ScopeValidationResult",
82
+ "registry",
83
+ "DryRunReport",
84
+ "DryRunSimulator",
85
+ "RateTracker",
86
+ "ActionExecutor",
87
+ "ActionExecutorError",
88
+ "ActionResult",
89
+ "ExecutionContext",
90
+ "SandboxedActionExecutor",
91
+ "SandboxedExecutorError",
92
+ "SandboxParams",
93
+ "SandboxResult",
94
+ "ActionTrace",
95
+ "build_action_trace",
96
+ "sign_action_trace",
97
+ "verify_action_trace",
98
+ "RollbackManager",
99
+ "RollbackResult",
100
+ "RollbackError",
101
+ "ApprovalStore",
102
+ "ApprovalError",
103
+ "HumanApprovalGate",
104
+ "PendingExecution",
105
+ ]
@@ -0,0 +1,176 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Roberto Llamosas Conde
3
+
4
+ from __future__ import annotations
5
+
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import TYPE_CHECKING
10
+
11
+ from matrixai.actions.dryrun import _input_hash as _hash_input
12
+
13
+ if TYPE_CHECKING:
14
+ from matrixai.actions.executor import ExecutionContext
15
+
16
+ _DEFAULT_TTL_SECONDS = 300 # 5 minutes
17
+
18
+
19
+ @dataclass
20
+ class PendingExecution:
21
+ execution_id: str
22
+ contract_name: str
23
+ capability: str
24
+ input_data: dict
25
+ input_hash: str # hash of input_data at submit time
26
+ model_hash: str
27
+ parameter_set_id: str
28
+ action_contract_hash: str
29
+ created_at: str # ISO8601 UTC
30
+ expires_at: str # ISO8601 UTC
31
+ status: str = "pending" # pending | approved | rejected | expired
32
+ channel: str | None = None
33
+ decided_at: str | None = None
34
+ decided_by: str | None = None
35
+
36
+ def is_expired(self, now: datetime | None = None) -> bool:
37
+ ts = now or datetime.now(tz=timezone.utc)
38
+ return ts >= datetime.fromisoformat(self.expires_at)
39
+
40
+ def is_approved(self, now: datetime | None = None) -> bool:
41
+ return self.status == "approved" and not self.is_expired(now)
42
+
43
+
44
+ class ApprovalStore:
45
+ """In-memory store for PendingExecution records."""
46
+
47
+ def __init__(self) -> None:
48
+ self._store: dict[str, PendingExecution] = {}
49
+
50
+ def submit(
51
+ self,
52
+ context: "ExecutionContext",
53
+ *,
54
+ ttl_seconds: int = _DEFAULT_TTL_SECONDS,
55
+ channel: str | None = None,
56
+ now: datetime | None = None,
57
+ ) -> PendingExecution:
58
+ ts = now or datetime.now(tz=timezone.utc)
59
+ execution_id = str(uuid.uuid4())
60
+ pending = PendingExecution(
61
+ execution_id=execution_id,
62
+ contract_name=context.contract.name,
63
+ capability=context.contract.capability,
64
+ input_data=context.input_data,
65
+ input_hash=context.dry_run_report.input_hash,
66
+ model_hash=context.model_hash,
67
+ parameter_set_id=context.parameter_set_id,
68
+ action_contract_hash=context.dry_run_report.action_contract_hash,
69
+ created_at=ts.isoformat(),
70
+ expires_at=(ts + timedelta(seconds=ttl_seconds)).isoformat(),
71
+ status="pending",
72
+ channel=channel,
73
+ )
74
+ self._store[execution_id] = pending
75
+ return pending
76
+
77
+ def get(self, execution_id: str) -> PendingExecution | None:
78
+ return self._store.get(execution_id)
79
+
80
+ def approve(
81
+ self,
82
+ execution_id: str,
83
+ *,
84
+ decided_by: str | None = None,
85
+ now: datetime | None = None,
86
+ ) -> PendingExecution:
87
+ pending = self._get_or_raise(execution_id)
88
+ self._check_not_decided(pending)
89
+ if pending.is_expired(now):
90
+ pending.status = "expired"
91
+ raise ApprovalError(f"Execution {execution_id!r} has expired")
92
+ ts = now or datetime.now(tz=timezone.utc)
93
+ pending.status = "approved"
94
+ pending.decided_at = ts.isoformat()
95
+ pending.decided_by = decided_by
96
+ return pending
97
+
98
+ def reject(
99
+ self,
100
+ execution_id: str,
101
+ *,
102
+ decided_by: str | None = None,
103
+ now: datetime | None = None,
104
+ ) -> PendingExecution:
105
+ pending = self._get_or_raise(execution_id)
106
+ self._check_not_decided(pending)
107
+ ts = now or datetime.now(tz=timezone.utc)
108
+ pending.status = "rejected"
109
+ pending.decided_at = ts.isoformat()
110
+ pending.decided_by = decided_by
111
+ return pending
112
+
113
+ def list_pending(self, now: datetime | None = None) -> list[PendingExecution]:
114
+ ts = now or datetime.now(tz=timezone.utc)
115
+ result = []
116
+ for p in self._store.values():
117
+ if p.status == "pending":
118
+ if p.is_expired(ts):
119
+ p.status = "expired"
120
+ else:
121
+ result.append(p)
122
+ return result
123
+
124
+ def _get_or_raise(self, execution_id: str) -> PendingExecution:
125
+ p = self._store.get(execution_id)
126
+ if p is None:
127
+ raise ApprovalError(f"No pending execution found with id {execution_id!r}")
128
+ return p
129
+
130
+ @staticmethod
131
+ def _check_not_decided(pending: PendingExecution) -> None:
132
+ if pending.status in ("approved", "rejected"):
133
+ raise ApprovalError(
134
+ f"Execution {pending.execution_id!r} is already {pending.status}"
135
+ )
136
+
137
+
138
+ class ApprovalError(Exception):
139
+ pass
140
+
141
+
142
+ class HumanApprovalGate:
143
+ """Check whether a context requires human approval and whether it has been granted."""
144
+
145
+ def __init__(self, store: ApprovalStore) -> None:
146
+ self._store = store
147
+
148
+ def requires_approval(self, context: "ExecutionContext") -> bool:
149
+ return context.contract.human_approval
150
+
151
+ def find_approved(
152
+ self,
153
+ context: "ExecutionContext",
154
+ now: datetime | None = None,
155
+ ) -> PendingExecution | None:
156
+ """Return the first approved, non-expired PendingExecution matching this context."""
157
+ for pending in self._store._store.values():
158
+ if (
159
+ pending.action_contract_hash == context.dry_run_report.action_contract_hash
160
+ and pending.input_hash == context.dry_run_report.input_hash
161
+ and pending.model_hash == context.model_hash
162
+ and pending.parameter_set_id == context.parameter_set_id
163
+ and pending.is_approved(now)
164
+ ):
165
+ return pending
166
+ return None
167
+
168
+ def check(
169
+ self,
170
+ context: "ExecutionContext",
171
+ now: datetime | None = None,
172
+ ) -> bool:
173
+ """Return True if approval is not required, or if a valid approval exists."""
174
+ if not self.requires_approval(context):
175
+ return True
176
+ return self.find_approved(context, now) is not None
@@ -0,0 +1,164 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Roberto Llamosas Conde
3
+
4
+ """P21 C10 — P20 action contract integration with composite models."""
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ComponentChainEntry:
13
+ alias: str
14
+ registry_name: str
15
+ version: str
16
+ mode: str
17
+ entry_hash: str
18
+ interpretability_level: str
19
+ is_terminal: bool
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CompositeAuditResult:
24
+ composite_model_hash: str
25
+ component_chain: list[ComponentChainEntry]
26
+ errors: list[str]
27
+ warnings: list[str]
28
+
29
+ @property
30
+ def ok(self) -> bool:
31
+ return not self.errors
32
+
33
+
34
+ def get_component_chain(program: Any, registry: Any) -> list[ComponentChainEntry]:
35
+ """Return ordered list of imported components with registry info."""
36
+ from matrixai.registry.model_registry import EntryNotFoundError
37
+
38
+ # Find terminal nodes: nodes that have no outgoing edges to other import nodes
39
+ import_aliases = {imp.alias for imp in getattr(program, "imports", [])}
40
+ out_nodes = {src for src, dst in program.graph.edges}
41
+ terminal_aliases = import_aliases - (import_aliases & out_nodes)
42
+
43
+ chain = []
44
+ for imp in sorted(getattr(program, "imports", []), key=lambda x: x.alias):
45
+ try:
46
+ entry = registry.get(imp.registry_name, imp.version)
47
+ chain.append(ComponentChainEntry(
48
+ alias=imp.alias,
49
+ registry_name=imp.registry_name,
50
+ version=imp.version,
51
+ mode=imp.mode,
52
+ entry_hash=entry.entry_hash,
53
+ interpretability_level=entry.interpretability_level,
54
+ is_terminal=imp.alias in terminal_aliases,
55
+ ))
56
+ except EntryNotFoundError:
57
+ chain.append(ComponentChainEntry(
58
+ alias=imp.alias,
59
+ registry_name=imp.registry_name,
60
+ version=imp.version,
61
+ mode=imp.mode,
62
+ entry_hash="",
63
+ interpretability_level="unknown",
64
+ is_terminal=imp.alias in terminal_aliases,
65
+ ))
66
+ return chain
67
+
68
+
69
+ def validate_composite_action_contract(
70
+ program: Any,
71
+ registry: Any,
72
+ *,
73
+ contract: Any = None,
74
+ ) -> CompositeAuditResult:
75
+ """Validate that the composite program satisfies action contract rules.
76
+
77
+ Rules:
78
+ - Intermediate imported components must not have real_with_audit actions (blockers).
79
+ - Only the terminal node may carry an action contract.
80
+ """
81
+ from matrixai.registry.composite_hash import compute_composite_model_hash
82
+ from matrixai.registry.model_registry import EntryNotFoundError
83
+
84
+ errors: list[str] = []
85
+ warnings: list[str] = []
86
+
87
+ import_aliases = {imp.alias for imp in getattr(program, "imports", [])}
88
+ # A node is intermediate if it has both incoming AND outgoing edges to/from import nodes
89
+ in_nodes = {dst for src, dst in program.graph.edges}
90
+ out_nodes = {src for src, dst in program.graph.edges}
91
+ intermediate_aliases = import_aliases & in_nodes & out_nodes
92
+
93
+ for imp in getattr(program, "imports", []):
94
+ if imp.alias not in intermediate_aliases:
95
+ continue
96
+ try:
97
+ entry = registry.get(imp.registry_name, imp.version)
98
+ blockers = getattr(entry, "blockers", [])
99
+ if "real_with_audit_action" in blockers:
100
+ errors.append(
101
+ f"Intermediate component {imp.alias!r} has real_with_audit action — "
102
+ f"only the terminal component may carry real actions"
103
+ )
104
+ except EntryNotFoundError:
105
+ errors.append(f"Component {imp.alias!r} not found in registry")
106
+
107
+ try:
108
+ composite_hash = compute_composite_model_hash(program, registry)
109
+ except Exception as exc: # noqa: BLE001
110
+ errors.append(f"Cannot compute composite_model_hash: {exc}")
111
+ composite_hash = ""
112
+
113
+ chain = get_component_chain(program, registry)
114
+ return CompositeAuditResult(
115
+ composite_model_hash=composite_hash,
116
+ component_chain=chain,
117
+ errors=errors,
118
+ warnings=warnings,
119
+ )
120
+
121
+
122
+ def composite_dry_run(
123
+ program: Any,
124
+ registry: Any,
125
+ input_data: Any,
126
+ *,
127
+ parameter_set: Any = None,
128
+ ) -> dict[str, Any]:
129
+ """Resolve all imports and simulate forward pass for dry-run validation."""
130
+ from matrixai.registry.resolver import CompositeModelResolver, ImportResolutionError
131
+ from matrixai.registry.composite_hash import compute_composite_model_hash
132
+
133
+ errors: list[str] = []
134
+
135
+ # Resolve all imports
136
+ resolver = CompositeModelResolver(registry)
137
+ try:
138
+ hydrated_imports = resolver.resolve(program, allow_mutable_tags=True)
139
+ except ImportResolutionError as exc:
140
+ errors.append(str(exc))
141
+ hydrated_imports = []
142
+
143
+ composite_hash = ""
144
+ if not errors:
145
+ try:
146
+ composite_hash = compute_composite_model_hash(program, registry)
147
+ except Exception as exc: # noqa: BLE001
148
+ errors.append(str(exc))
149
+
150
+ return {
151
+ "ok": not errors,
152
+ "errors": errors,
153
+ "resolved_imports": [
154
+ {
155
+ "alias": imp.alias,
156
+ "registry_name": imp.registry_name,
157
+ "version": imp.version,
158
+ "resolved_entry_hash": imp.resolved_entry_hash,
159
+ }
160
+ for imp in hydrated_imports
161
+ ],
162
+ "composite_model_hash": composite_hash,
163
+ "input": input_data,
164
+ }
@@ -0,0 +1,131 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Roberto Llamosas Conde
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass
10
+
11
+ from matrixai.actions.registry import registry
12
+ from matrixai.actions.schema import (
13
+ ActionContractSpec,
14
+ CAPABILITIES,
15
+ RateLimitSpec,
16
+ RollbackSpec,
17
+ SandboxLimitsSpec,
18
+ )
19
+ from matrixai.ir.schema import MatrixAIProgram
20
+
21
+
22
+ # ── canonical serialization ───────────────────────────────────────────────────
23
+
24
+ def _ser_rollback(rb: RollbackSpec | None) -> dict | None:
25
+ if rb is None:
26
+ return None
27
+ return {
28
+ "capability": rb.capability,
29
+ "name": rb.name,
30
+ "scope": dict(sorted(rb.scope.items())),
31
+ }
32
+
33
+
34
+ def _ser_rate_limit(rl: RateLimitSpec | None) -> dict | None:
35
+ if rl is None:
36
+ return None
37
+ return {"per_hour": rl.per_hour, "per_minute": rl.per_minute}
38
+
39
+
40
+ def _ser_sandbox_limits(sl: SandboxLimitsSpec | None) -> dict | None:
41
+ if sl is None:
42
+ return None
43
+ return {
44
+ "allowed_env_vars": sorted(sl.allowed_env_vars),
45
+ "max_cpu_seconds": sl.max_cpu_seconds,
46
+ "max_memory_mb": sl.max_memory_mb,
47
+ "max_wall_seconds": sl.max_wall_seconds,
48
+ "no_network": sl.no_network,
49
+ }
50
+
51
+
52
+ def canonical_dict(spec: ActionContractSpec) -> dict:
53
+ """Canonical dict for deterministic hashing — keys always sorted."""
54
+ return {
55
+ "approval_channel": spec.approval_channel,
56
+ "approval_timeout_seconds": spec.approval_timeout_seconds,
57
+ "capability": spec.capability,
58
+ "dry_run_required": spec.dry_run_required,
59
+ "human_approval": spec.human_approval,
60
+ "name": spec.name,
61
+ "rate_limit": _ser_rate_limit(spec.rate_limit),
62
+ "risk_level": CAPABILITIES[spec.capability],
63
+ "rollback": _ser_rollback(spec.rollback),
64
+ "sandbox_limits": _ser_sandbox_limits(spec.sandbox_limits),
65
+ "sandbox_required": spec.sandbox_required,
66
+ "scope": dict(sorted(spec.scope.items())),
67
+ "signature_required": spec.signature_required,
68
+ }
69
+
70
+
71
+ def compute_action_contract_hash(spec: ActionContractSpec) -> str:
72
+ """Return 'sha256:<hex>' of the canonical JSON of spec."""
73
+ payload = json.dumps(canonical_dict(spec), sort_keys=True, separators=(",", ":"))
74
+ digest = hashlib.sha256(payload.encode()).hexdigest()
75
+ return f"sha256:{digest}"
76
+
77
+
78
+ # ── compatibility validation ──────────────────────────────────────────────────
79
+
80
+ @dataclass
81
+ class ActionContractValidationResult:
82
+ ok: bool
83
+ errors: list[str]
84
+
85
+
86
+ def validate_action_contract(
87
+ spec: ActionContractSpec,
88
+ program: MatrixAIProgram,
89
+ ) -> ActionContractValidationResult:
90
+ """Validate that spec is compatible with the .mxai program."""
91
+ errors: list[str] = []
92
+
93
+ matching = [a for a in program.actions if a.name == spec.name]
94
+ if not matching:
95
+ errors.append(f"Action {spec.name!r} not found in program actions")
96
+ return ActionContractValidationResult(ok=False, errors=errors)
97
+
98
+ action = matching[0]
99
+
100
+ if action.target and action.target != spec.capability:
101
+ errors.append(
102
+ f"Action {spec.name!r} TARGET {action.target!r} does not match "
103
+ f"contract CAPABILITY {spec.capability!r}"
104
+ )
105
+
106
+ if action.policy != "real_with_audit":
107
+ errors.append(
108
+ f"Action {spec.name!r} must have POLICY real_with_audit, got {action.policy!r}"
109
+ )
110
+
111
+ scope_result = registry.validate_scope(spec.capability, spec.scope)
112
+ if not scope_result.ok:
113
+ errors.extend(scope_result.errors)
114
+
115
+ return ActionContractValidationResult(ok=len(errors) == 0, errors=errors)
116
+
117
+
118
+ # ── signing key check ─────────────────────────────────────────────────────────
119
+
120
+ def check_signing_key_available() -> bool:
121
+ """Return True if MATRIXAI_ACTION_SIGNING_KEY is set in environment."""
122
+ return bool(os.environ.get("MATRIXAI_ACTION_SIGNING_KEY", ""))
123
+
124
+
125
+ def require_signing_key(spec: ActionContractSpec) -> None:
126
+ """Raise RuntimeError if spec requires a signing key that is not available."""
127
+ if spec.signature_required and not check_signing_key_available():
128
+ raise RuntimeError(
129
+ f"ActionContract {spec.name!r} requires MATRIXAI_ACTION_SIGNING_KEY "
130
+ f"but it is not set in the environment"
131
+ )