macp-sdk-python 0.2.1__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.
macp_sdk/policy.py ADDED
@@ -0,0 +1,292 @@
1
+ """Typed policy builders for all MACP governance modes.
2
+
3
+ Each builder produces a :class:`PolicyDescriptor` with JSON-encoded rules
4
+ that match the normative rule schemas defined in RFC-MACP-0012 and the
5
+ Rust runtime's ``src/policy/rules.rs``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+
13
+ from macp.v1 import policy_pb2
14
+
15
+ # ── Shared commitment rules (all modes) ─────────────────────────────
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class CommitmentRules:
20
+ """Commitment authority configuration shared by all mode policies."""
21
+
22
+ authority: str = "initiator_only"
23
+ designated_roles: list[str] = field(default_factory=list)
24
+ require_vote_quorum: bool = False
25
+
26
+
27
+ def _commitment_dict(c: CommitmentRules) -> dict[str, object]:
28
+ return {
29
+ "authority": c.authority,
30
+ "designated_roles": c.designated_roles,
31
+ "require_vote_quorum": c.require_vote_quorum,
32
+ }
33
+
34
+
35
+ # ── Decision mode ────────────────────────────────────────────────────
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class VotingRules:
40
+ """Voting configuration for a decision policy."""
41
+
42
+ algorithm: str = "none"
43
+ threshold: float = 0.5
44
+ quorum_type: str | None = None
45
+ quorum_value: float | None = None
46
+ weights: dict[str, float] | None = None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ObjectionHandlingRules:
51
+ """Objection handling configuration for a decision policy."""
52
+
53
+ critical_severity_vetoes: bool = False
54
+ veto_threshold: int = 1
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class EvaluationRules:
59
+ """Evaluation configuration for a decision policy."""
60
+
61
+ minimum_confidence: float = 0.0
62
+ required_before_voting: bool = False
63
+
64
+
65
+ def build_decision_policy(
66
+ policy_id: str,
67
+ description: str,
68
+ *,
69
+ voting: VotingRules | None = None,
70
+ objection_handling: ObjectionHandlingRules | None = None,
71
+ evaluation: EvaluationRules | None = None,
72
+ commitment: CommitmentRules | None = None,
73
+ ) -> policy_pb2.PolicyDescriptor:
74
+ """Build a PolicyDescriptor for Decision mode governance."""
75
+ v = voting or VotingRules()
76
+ o = objection_handling or ObjectionHandlingRules()
77
+ e = evaluation or EvaluationRules()
78
+ c = commitment or CommitmentRules()
79
+
80
+ voting_section: dict[str, object] = {
81
+ "algorithm": v.algorithm,
82
+ "threshold": v.threshold,
83
+ "quorum": {"type": v.quorum_type or "count", "value": v.quorum_value or 0},
84
+ }
85
+ if v.weights is not None:
86
+ voting_section["weights"] = v.weights
87
+
88
+ rules: dict[str, object] = {
89
+ "voting": voting_section,
90
+ "objection_handling": {
91
+ "critical_severity_vetoes": o.critical_severity_vetoes,
92
+ "veto_threshold": o.veto_threshold,
93
+ },
94
+ "evaluation": {
95
+ "minimum_confidence": e.minimum_confidence,
96
+ "required_before_voting": e.required_before_voting,
97
+ },
98
+ "commitment": _commitment_dict(c),
99
+ }
100
+
101
+ return policy_pb2.PolicyDescriptor(
102
+ policy_id=policy_id,
103
+ mode="macp.mode.decision.v1",
104
+ description=description,
105
+ rules=json.dumps(rules).encode(),
106
+ schema_version=1,
107
+ )
108
+
109
+
110
+ # ── Quorum mode ──────────────────────────────────────────────────────
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class QuorumThreshold:
115
+ """Quorum threshold configuration (RFC: ``threshold`` object)."""
116
+
117
+ type: str = "n_of_m"
118
+ value: float = 0
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class AbstentionRules:
123
+ """Abstention handling configuration (RFC: ``abstention`` object)."""
124
+
125
+ counts_toward_quorum: bool = False
126
+ interpretation: str = "neutral"
127
+
128
+
129
+ def build_quorum_policy(
130
+ policy_id: str,
131
+ description: str,
132
+ *,
133
+ threshold: QuorumThreshold | None = None,
134
+ abstention: AbstentionRules | None = None,
135
+ commitment: CommitmentRules | None = None,
136
+ ) -> policy_pb2.PolicyDescriptor:
137
+ """Build a PolicyDescriptor for Quorum mode governance."""
138
+ t = threshold or QuorumThreshold()
139
+ a = abstention or AbstentionRules()
140
+ c = commitment or CommitmentRules()
141
+
142
+ rules: dict[str, object] = {
143
+ "threshold": {"type": t.type, "value": t.value},
144
+ "abstention": {
145
+ "counts_toward_quorum": a.counts_toward_quorum,
146
+ "interpretation": a.interpretation,
147
+ },
148
+ "commitment": _commitment_dict(c),
149
+ }
150
+
151
+ return policy_pb2.PolicyDescriptor(
152
+ policy_id=policy_id,
153
+ mode="macp.mode.quorum.v1",
154
+ description=description,
155
+ rules=json.dumps(rules).encode(),
156
+ schema_version=1,
157
+ )
158
+
159
+
160
+ # ── Proposal mode ────────────────────────────────────────────────────
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class ProposalAcceptanceRules:
165
+ """Acceptance criterion for proposal policies."""
166
+
167
+ criterion: str = "all_parties"
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class CounterProposalRules:
172
+ """Counter-proposal limits for proposal policies."""
173
+
174
+ max_rounds: int = 0
175
+
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class RejectionRules:
179
+ """Rejection handling for proposal policies."""
180
+
181
+ terminal_on_any_reject: bool = False
182
+
183
+
184
+ def build_proposal_policy(
185
+ policy_id: str,
186
+ description: str,
187
+ *,
188
+ acceptance: ProposalAcceptanceRules | None = None,
189
+ counter_proposal: CounterProposalRules | None = None,
190
+ rejection: RejectionRules | None = None,
191
+ commitment: CommitmentRules | None = None,
192
+ ) -> policy_pb2.PolicyDescriptor:
193
+ """Build a PolicyDescriptor for Proposal mode governance."""
194
+ acc = acceptance or ProposalAcceptanceRules()
195
+ cp = counter_proposal or CounterProposalRules()
196
+ rej = rejection or RejectionRules()
197
+ c = commitment or CommitmentRules()
198
+
199
+ rules: dict[str, object] = {
200
+ "acceptance": {"criterion": acc.criterion},
201
+ "counter_proposal": {"max_rounds": cp.max_rounds},
202
+ "rejection": {"terminal_on_any_reject": rej.terminal_on_any_reject},
203
+ "commitment": _commitment_dict(c),
204
+ }
205
+
206
+ return policy_pb2.PolicyDescriptor(
207
+ policy_id=policy_id,
208
+ mode="macp.mode.proposal.v1",
209
+ description=description,
210
+ rules=json.dumps(rules).encode(),
211
+ schema_version=1,
212
+ )
213
+
214
+
215
+ # ── Task mode ────────────────────────────────────────────────────────
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class TaskAssignmentRules:
220
+ """Task assignment configuration."""
221
+
222
+ allow_reassignment_on_reject: bool = False
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class TaskCompletionRules:
227
+ """Task completion configuration."""
228
+
229
+ require_output: bool = False
230
+
231
+
232
+ def build_task_policy(
233
+ policy_id: str,
234
+ description: str,
235
+ *,
236
+ assignment: TaskAssignmentRules | None = None,
237
+ completion: TaskCompletionRules | None = None,
238
+ commitment: CommitmentRules | None = None,
239
+ ) -> policy_pb2.PolicyDescriptor:
240
+ """Build a PolicyDescriptor for Task mode governance."""
241
+ a = assignment or TaskAssignmentRules()
242
+ comp = completion or TaskCompletionRules()
243
+ c = commitment or CommitmentRules()
244
+
245
+ rules: dict[str, object] = {
246
+ "assignment": {"allow_reassignment_on_reject": a.allow_reassignment_on_reject},
247
+ "completion": {"require_output": comp.require_output},
248
+ "commitment": _commitment_dict(c),
249
+ }
250
+
251
+ return policy_pb2.PolicyDescriptor(
252
+ policy_id=policy_id,
253
+ mode="macp.mode.task.v1",
254
+ description=description,
255
+ rules=json.dumps(rules).encode(),
256
+ schema_version=1,
257
+ )
258
+
259
+
260
+ # ── Handoff mode ─────────────────────────────────────────────────────
261
+
262
+
263
+ @dataclass(frozen=True, slots=True)
264
+ class HandoffAcceptanceRules:
265
+ """Handoff acceptance configuration."""
266
+
267
+ implicit_accept_timeout_ms: int = 0
268
+
269
+
270
+ def build_handoff_policy(
271
+ policy_id: str,
272
+ description: str,
273
+ *,
274
+ acceptance: HandoffAcceptanceRules | None = None,
275
+ commitment: CommitmentRules | None = None,
276
+ ) -> policy_pb2.PolicyDescriptor:
277
+ """Build a PolicyDescriptor for Handoff mode governance."""
278
+ acc = acceptance or HandoffAcceptanceRules()
279
+ c = commitment or CommitmentRules()
280
+
281
+ rules: dict[str, object] = {
282
+ "acceptance": {"implicit_accept_timeout_ms": acc.implicit_accept_timeout_ms},
283
+ "commitment": _commitment_dict(c),
284
+ }
285
+
286
+ return policy_pb2.PolicyDescriptor(
287
+ policy_id=policy_id,
288
+ mode="macp.mode.handoff.v1",
289
+ description=description,
290
+ rules=json.dumps(rules).encode(),
291
+ schema_version=1,
292
+ )
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from macp.modes.decision.v1 import decision_pb2
6
+ from macp.v1 import envelope_pb2
7
+
8
+ from .base_projection import BaseProjection
9
+ from .constants import MODE_DECISION
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class DecisionProposalRecord:
14
+ proposal_id: str
15
+ option: str
16
+ rationale: str
17
+ sender: str
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class DecisionEvaluationRecord:
22
+ proposal_id: str
23
+ recommendation: str
24
+ confidence: float
25
+ reason: str
26
+ sender: str
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class DecisionObjectionRecord:
31
+ proposal_id: str
32
+ reason: str
33
+ severity: str
34
+ sender: str
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class DecisionVoteRecord:
39
+ proposal_id: str
40
+ vote: str
41
+ reason: str
42
+ sender: str
43
+
44
+
45
+ class DecisionProjection(BaseProjection):
46
+ """In-process state tracking for Decision mode sessions."""
47
+
48
+ MODE = MODE_DECISION
49
+
50
+ def __init__(self) -> None:
51
+ super().__init__()
52
+ self.phase = "Proposal"
53
+ self.proposals: dict[str, DecisionProposalRecord] = {}
54
+ self.evaluations: list[DecisionEvaluationRecord] = []
55
+ self.objections: list[DecisionObjectionRecord] = []
56
+ self.votes: dict[str, dict[str, DecisionVoteRecord]] = {}
57
+
58
+ def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
59
+ message_type = envelope.message_type
60
+
61
+ if message_type == "Proposal":
62
+ payload = decision_pb2.ProposalPayload()
63
+ payload.ParseFromString(envelope.payload)
64
+ self.proposals[payload.proposal_id] = DecisionProposalRecord(
65
+ proposal_id=payload.proposal_id,
66
+ option=payload.option,
67
+ rationale=payload.rationale,
68
+ sender=envelope.sender,
69
+ )
70
+ return
71
+
72
+ if message_type == "Evaluation":
73
+ payload = decision_pb2.EvaluationPayload()
74
+ payload.ParseFromString(envelope.payload)
75
+ self.evaluations.append(
76
+ DecisionEvaluationRecord(
77
+ proposal_id=payload.proposal_id,
78
+ recommendation=payload.recommendation,
79
+ confidence=payload.confidence,
80
+ reason=payload.reason,
81
+ sender=envelope.sender,
82
+ )
83
+ )
84
+ self.phase = "Evaluation"
85
+ return
86
+
87
+ if message_type == "Objection":
88
+ payload = decision_pb2.ObjectionPayload()
89
+ payload.ParseFromString(envelope.payload)
90
+ self.objections.append(
91
+ DecisionObjectionRecord(
92
+ proposal_id=payload.proposal_id,
93
+ reason=payload.reason,
94
+ severity=payload.severity or "medium",
95
+ sender=envelope.sender,
96
+ )
97
+ )
98
+ return
99
+
100
+ if message_type == "Vote":
101
+ payload = decision_pb2.VotePayload()
102
+ payload.ParseFromString(envelope.payload)
103
+ self.votes.setdefault(payload.proposal_id, {})[envelope.sender] = DecisionVoteRecord(
104
+ proposal_id=payload.proposal_id,
105
+ vote=payload.vote,
106
+ reason=payload.reason,
107
+ sender=envelope.sender,
108
+ )
109
+ self.phase = "Voting"
110
+
111
+ # -- State query helpers (no policy enforcement) --
112
+
113
+ def vote_totals(self) -> dict[str, int]:
114
+ """Count votes per proposal, keyed by proposal_id.
115
+
116
+ ABSTAIN votes are tracked but excluded from the totals returned
117
+ here (which counts only APPROVE votes).
118
+ """
119
+ totals: dict[str, int] = {}
120
+ for proposal_id, sender_votes in self.votes.items():
121
+ totals[proposal_id] = sum(
122
+ 1 for vote in sender_votes.values() if _is_positive_vote(vote.vote)
123
+ )
124
+ return totals
125
+
126
+ def majority_winner(self) -> str | None:
127
+ """Return the proposal_id with a majority of non-abstain votes, or None.
128
+
129
+ ABSTAIN votes are excluded from the denominator per RFC-MACP-0004.
130
+ """
131
+ totals = self.vote_totals()
132
+ if not totals:
133
+ return None
134
+ # Count total non-abstain votes across all proposals
135
+ non_abstain = 0
136
+ for sender_votes in self.votes.values():
137
+ for vote in sender_votes.values():
138
+ if vote.vote.upper() != "ABSTAIN":
139
+ non_abstain += 1
140
+ if non_abstain == 0:
141
+ return None
142
+ for proposal_id, count in totals.items():
143
+ if count / non_abstain > 0.5:
144
+ return proposal_id
145
+ return None
146
+
147
+ def has_blocking_objection(self, proposal_id: str | None = None) -> bool:
148
+ """Check if any objection with ``critical`` severity exists.
149
+
150
+ Only ``critical`` severity triggers a veto per the updated runtime.
151
+ """
152
+ return any(
153
+ objection.severity.lower() == "critical"
154
+ and (proposal_id is None or objection.proposal_id == proposal_id)
155
+ for objection in self.objections
156
+ )
157
+
158
+ def review_evaluations(self) -> list[DecisionEvaluationRecord]:
159
+ """Return evaluations with REVIEW recommendation (informational only)."""
160
+ return [e for e in self.evaluations if e.recommendation.upper() == "REVIEW"]
161
+
162
+ def qualifying_evaluations(self) -> list[DecisionEvaluationRecord]:
163
+ """Return evaluations that are *not* REVIEW (i.e., they affect decisions)."""
164
+ return [e for e in self.evaluations if e.recommendation.upper() != "REVIEW"]
165
+
166
+ def vote_ratio(self, proposal_id: str) -> float:
167
+ """Return the APPROVE vote ratio for *proposal_id*.
168
+
169
+ ABSTAIN votes are excluded from the denominator.
170
+ """
171
+ sender_votes = self.votes.get(proposal_id)
172
+ if not sender_votes:
173
+ return 0.0
174
+ votes = list(sender_votes.values())
175
+ non_abstain = [v for v in votes if v.vote.upper() != "ABSTAIN"]
176
+ if not non_abstain:
177
+ return 0.0
178
+ approvals = sum(1 for v in non_abstain if _is_positive_vote(v.vote))
179
+ return approvals / len(non_abstain)
180
+
181
+
182
+ def _is_positive_vote(vote: str) -> bool:
183
+ return vote.strip().upper() in {"APPROVE", "APPROVED", "YES", "ACCEPT", "ACCEPTED"}