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/__init__.py +207 -0
- macp_sdk/_logging.py +16 -0
- macp_sdk/agent/__init__.py +64 -0
- macp_sdk/agent/dispatcher.py +105 -0
- macp_sdk/agent/participant.py +404 -0
- macp_sdk/agent/runner.py +100 -0
- macp_sdk/agent/strategies.py +312 -0
- macp_sdk/agent/transports.py +153 -0
- macp_sdk/agent/types.py +64 -0
- macp_sdk/auth.py +74 -0
- macp_sdk/base_projection.py +52 -0
- macp_sdk/base_session.py +169 -0
- macp_sdk/client.py +625 -0
- macp_sdk/constants.py +20 -0
- macp_sdk/decision.py +154 -0
- macp_sdk/envelope.py +180 -0
- macp_sdk/errors.py +101 -0
- macp_sdk/handoff.py +241 -0
- macp_sdk/policy.py +292 -0
- macp_sdk/projections.py +183 -0
- macp_sdk/proposal.py +305 -0
- macp_sdk/proto_registry.py +150 -0
- macp_sdk/py.typed +0 -0
- macp_sdk/quorum.py +258 -0
- macp_sdk/retry.py +64 -0
- macp_sdk/task.py +368 -0
- macp_sdk/validation.py +117 -0
- macp_sdk/watchers.py +122 -0
- macp_sdk_python-0.2.1.dist-info/METADATA +165 -0
- macp_sdk_python-0.2.1.dist-info/RECORD +33 -0
- macp_sdk_python-0.2.1.dist-info/WHEEL +5 -0
- macp_sdk_python-0.2.1.dist-info/licenses/LICENSE +201 -0
- macp_sdk_python-0.2.1.dist-info/top_level.txt +1 -0
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
|
+
)
|
macp_sdk/projections.py
ADDED
|
@@ -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"}
|