Skip to content

Commit 1107ac2

Browse files
committed
feat(cmpc): closure-predicate evaluator + bilateral type profile (PR 3 of 6)
PR 3 of the 6-PR CMPC Stage 3 bilateral campaign. Builds on PR #29 state machine. - concordia/cmpc/predicate.py: ClosurePredicate, PredicateResult, evaluate_predicate - Boolean composition: and/or/not - Comparison: ==, !=, >=, <=, >, < - Set membership: in - Time comparison: before/after on ISO 8601 strings - Aggregation: sum, min, max, count over all chain commitments - Bilateral type profile registered: urn:concordia:predicate-type:bilateral_chain_closure:v1 - Profile evaluator: expected_participants subset check + aggregate quantity within tolerance + timing pre-deadline + optional mandate-proof presence - Fixture matrix covers Boolean + comparison + aggregation + bilateral-profile happy + 4 failure modes - pytest 1168 + mypy clean within baseline v0.7.0a0 track preserved; no PyPI publish. Design doc: Review/Concordia/CMPC_Stage_3_Bilateral_Beer_Game_Design_Doc_2026-05-16.md
1 parent b47281c commit 1107ac2

21 files changed

Lines changed: 535 additions & 1 deletion

concordia/cmpc/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
verify_transcript,
2222
)
2323
from .errors import CMPCError, InvalidPrimitiveError, SchemaValidationError
24+
from .predicate import (
25+
ClosurePredicate,
26+
PredicateResult,
27+
evaluate_predicate,
28+
)
2429
from .signing import (
2530
sign_atomic_activation_proof,
2631
sign_conditional_commitment,
@@ -31,7 +36,6 @@
3136
)
3237
from .types import (
3338
AtomicActivationProof,
34-
ClosurePredicate,
3539
ConditionalCommitment,
3640
UnwindRecord,
3741
)
@@ -44,6 +48,7 @@
4448
"TransitionRecord",
4549
"ConditionalCommitment",
4650
"ClosurePredicate",
51+
"PredicateResult",
4752
"AtomicActivationProof",
4853
"UnwindRecord",
4954
"canonicalize_chain_session",
@@ -61,4 +66,5 @@
6166
"InvalidPrimitiveError",
6267
"SchemaValidationError",
6368
"verify_transcript",
69+
"evaluate_predicate",
6470
]

concordia/cmpc/predicate.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""CMPC closure-predicate evaluation."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from dataclasses import dataclass
7+
from datetime import datetime, timezone
8+
import operator
9+
from typing import Any, Literal
10+
11+
from concordia.cmpc.chain_session import ChainSession
12+
13+
14+
PredicateOutcome = Literal["satisfied", "unsatisfied"]
15+
16+
BILATERAL_CHAIN_CLOSURE_V1 = (
17+
"urn:concordia:predicate-type:bilateral_chain_closure:v1"
18+
)
19+
CLOSURE_LANGUAGE_V1 = "urn:concordia:predicate-type:closure_language:v1"
20+
21+
22+
@dataclass
23+
class PredicateResult:
24+
result: PredicateOutcome
25+
reason: str | None = None
26+
evidence: dict[str, Any] | None = None
27+
28+
29+
@dataclass
30+
class ClosurePredicate:
31+
predicate_id: str
32+
type_urn: str
33+
parameters: dict[str, Any]
34+
version: str = "1"
35+
36+
37+
def evaluate_predicate(
38+
predicate: ClosurePredicate,
39+
chain_session: ChainSession,
40+
commitments_list: list[dict[str, Any]] | None = None,
41+
) -> PredicateResult:
42+
commitments = commitments_list if commitments_list is not None else []
43+
evaluator = PROFILES.get(predicate.type_urn)
44+
if not evaluator:
45+
return PredicateResult(
46+
"unsatisfied",
47+
reason=f"unknown_predicate_type:{predicate.type_urn}",
48+
)
49+
return evaluator(predicate, chain_session, commitments)
50+
51+
52+
def evaluate_closure_language_v1(
53+
predicate: ClosurePredicate,
54+
chain_session: ChainSession,
55+
commitments_list: list[dict[str, Any]],
56+
) -> PredicateResult:
57+
node = predicate.parameters.get("expression")
58+
if not isinstance(node, dict):
59+
return PredicateResult("unsatisfied", reason="missing_expression")
60+
try:
61+
value = evaluate_node(node, chain_session, commitments_list)
62+
except (KeyError, TypeError, ValueError) as exc:
63+
return PredicateResult("unsatisfied", reason=str(exc))
64+
if bool(value):
65+
return PredicateResult("satisfied", evidence={"value": value})
66+
return PredicateResult("unsatisfied", reason="predicate_not_satisfied")
67+
68+
69+
def evaluate_node(
70+
node: dict[str, Any],
71+
chain_session: ChainSession,
72+
commitments: list[dict[str, Any]],
73+
) -> Any:
74+
op = node.get("op")
75+
if op == "and":
76+
return all(evaluate_node(arg, chain_session, commitments) for arg in node["args"])
77+
if op == "or":
78+
return any(evaluate_node(arg, chain_session, commitments) for arg in node["args"])
79+
if op == "not":
80+
return not evaluate_node(node["arg"], chain_session, commitments)
81+
if op in ("==", "!=", ">=", "<=", ">", "<"):
82+
return evaluate_comparison(node, chain_session, commitments)
83+
if op == "in":
84+
return evaluate_membership(node, commitments)
85+
if op in ("before", "after"):
86+
return evaluate_time(node, commitments)
87+
if op in ("sum", "min", "max", "count"):
88+
return evaluate_aggregation(node, commitments)
89+
raise ValueError(f"Unknown predicate op: {op}")
90+
91+
92+
def evaluate_comparison(
93+
node: dict[str, Any],
94+
chain_session: ChainSession,
95+
commitments: list[dict[str, Any]],
96+
) -> bool:
97+
op = node["op"]
98+
comparator = _COMPARATORS[op]
99+
expected = node["value"]
100+
101+
if "left" in node:
102+
actual = evaluate_node(node["left"], chain_session, commitments)
103+
return bool(comparator(actual, expected))
104+
105+
field = node["field"]
106+
return all(comparator(_get_field(commitment, field), expected) for commitment in commitments)
107+
108+
109+
def evaluate_membership(node: dict[str, Any], commitments: list[dict[str, Any]]) -> bool:
110+
accepted_values = set(node["values"])
111+
field = node["field"]
112+
return all(_get_field(commitment, field) in accepted_values for commitment in commitments)
113+
114+
115+
def evaluate_time(node: dict[str, Any], commitments: list[dict[str, Any]]) -> bool:
116+
op = node["op"]
117+
expected = _parse_iso_datetime(node["value"])
118+
field = node["field"]
119+
if op == "before":
120+
return all(_parse_iso_datetime(_get_field(c, field)) < expected for c in commitments)
121+
if op == "after":
122+
return all(_parse_iso_datetime(_get_field(c, field)) > expected for c in commitments)
123+
raise ValueError(f"Unknown time op: {op}")
124+
125+
126+
def evaluate_aggregation(node: dict[str, Any], commitments: list[dict[str, Any]]) -> int | float:
127+
op = node["op"]
128+
if op == "count":
129+
return len(commitments)
130+
131+
field = node["field"]
132+
values = [_get_field(commitment, field) for commitment in commitments]
133+
if not all(isinstance(value, int | float) for value in values):
134+
raise TypeError(f"Aggregation field is not numeric: {field}")
135+
numeric_values = [float(value) if isinstance(value, int) else value for value in values]
136+
137+
if op == "sum":
138+
return sum(numeric_values)
139+
if op == "min":
140+
return min(numeric_values)
141+
if op == "max":
142+
return max(numeric_values)
143+
raise ValueError(f"Unknown aggregation op: {op}")
144+
145+
146+
def evaluate_bilateral_chain_closure_v1(
147+
predicate: ClosurePredicate,
148+
chain_session: ChainSession,
149+
commitments_list: list[dict[str, Any]],
150+
) -> PredicateResult:
151+
params = predicate.parameters
152+
expected = set(params["expected_participants"])
153+
required_qty = params["aggregate_quantity_required"]
154+
tolerance = params.get("match_tolerance", 0.0)
155+
deadline = _parse_iso_datetime(params["activation_deadline_iso"])
156+
mandate_check = params.get("mandate_check_required", False)
157+
158+
actual_participants = {commitment["committer_did"] for commitment in commitments_list}
159+
if not actual_participants.issubset(expected):
160+
return PredicateResult(
161+
"unsatisfied",
162+
reason="unexpected_participants",
163+
evidence={
164+
"actual": sorted(actual_participants),
165+
"expected": sorted(expected),
166+
},
167+
)
168+
169+
total_qty = sum(
170+
commitment["commitment_terms"].get("quantity", 0)
171+
for commitment in commitments_list
172+
)
173+
if abs(total_qty - required_qty) > tolerance:
174+
return PredicateResult(
175+
"unsatisfied",
176+
reason="aggregate_quantity_mismatch",
177+
evidence={"actual": total_qty, "required": required_qty},
178+
)
179+
180+
now = datetime.now(timezone.utc)
181+
if now >= deadline:
182+
return PredicateResult(
183+
"unsatisfied",
184+
reason="past_activation_deadline",
185+
evidence={"now": now.isoformat(), "deadline": deadline.isoformat()},
186+
)
187+
188+
if mandate_check:
189+
for commitment in commitments_list:
190+
if not commitment.get("mandate_proof_id"):
191+
return PredicateResult(
192+
"unsatisfied",
193+
reason="missing_mandate_proof",
194+
evidence={"commitment_id": commitment.get("commitment_id")},
195+
)
196+
197+
return PredicateResult(
198+
"satisfied",
199+
evidence={
200+
"total_qty": total_qty,
201+
"expected_qty": required_qty,
202+
"chain_session_id": chain_session.chain_session_id,
203+
},
204+
)
205+
206+
207+
def _get_field(commitment: dict[str, Any], field_path: str) -> Any:
208+
current: Any = commitment
209+
for segment in field_path.split("."):
210+
if not isinstance(current, dict) or segment not in current:
211+
raise KeyError(f"Missing field: {field_path}")
212+
current = current[segment]
213+
return current
214+
215+
216+
def _parse_iso_datetime(value: Any) -> datetime:
217+
if isinstance(value, datetime):
218+
parsed = value
219+
elif isinstance(value, str):
220+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
221+
else:
222+
raise TypeError(f"Expected ISO 8601 datetime string, got {type(value).__name__}")
223+
if parsed.tzinfo is None:
224+
return parsed.replace(tzinfo=timezone.utc)
225+
return parsed
226+
227+
228+
_COMPARATORS: dict[str, Callable[[Any, Any], bool]] = {
229+
"==": operator.eq,
230+
"!=": operator.ne,
231+
">=": operator.ge,
232+
"<=": operator.le,
233+
">": operator.gt,
234+
"<": operator.lt,
235+
}
236+
237+
238+
PROFILES: dict[
239+
str,
240+
Callable[[ClosurePredicate, ChainSession, list[dict[str, Any]]], PredicateResult],
241+
] = {
242+
BILATERAL_CHAIN_CLOSURE_V1: evaluate_bilateral_chain_closure_v1,
243+
CLOSURE_LANGUAGE_V1: evaluate_closure_language_v1,
244+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Fixture coverage for CMPC closure-predicate evaluation."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime, timedelta, timezone
6+
import json
7+
import pathlib
8+
9+
import pytest
10+
11+
from concordia.cmpc import ClosurePredicate, evaluate_predicate
12+
from concordia.cmpc.chain_session import ChainSession, ChainSessionState
13+
14+
15+
FIXTURE_DIR = (
16+
pathlib.Path(__file__).parent.parent
17+
/ "fixtures"
18+
/ "cmpc_bilateral"
19+
/ "predicates"
20+
)
21+
22+
23+
def test_unknown_predicate_type_unsatisfied() -> None:
24+
predicate = ClosurePredicate(
25+
predicate_id="p1",
26+
type_urn="urn:concordia:predicate-type:nonexistent:v1",
27+
parameters={},
28+
)
29+
result = evaluate_predicate(predicate, _make_session(), [])
30+
assert result.result == "unsatisfied"
31+
assert "unknown_predicate_type" in (result.reason or "")
32+
33+
34+
@pytest.mark.parametrize("fixture_path", sorted(FIXTURE_DIR.glob("*.json")))
35+
def test_fixture(fixture_path: pathlib.Path) -> None:
36+
fixture = json.loads(fixture_path.read_text())
37+
predicate = ClosurePredicate(**fixture["predicate"])
38+
result = evaluate_predicate(predicate, _make_session(), fixture["commitments"])
39+
assert result.result == fixture["expected_result"]
40+
if fixture.get("expected_reason"):
41+
assert fixture["expected_reason"] in (result.reason or "")
42+
43+
44+
def _make_session() -> ChainSession:
45+
now = datetime.now(timezone.utc)
46+
return ChainSession(
47+
chain_session_id="urn:concordia:chain-session:test",
48+
participants=["did:web:r.test", "did:web:w.test"],
49+
closure_predicate_ref="urn:concordia:predicate:test",
50+
state=ChainSessionState.OPEN,
51+
created_at=now,
52+
activation_deadline=now + timedelta(hours=1),
53+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"predicate": {"predicate_id": "urn:concordia:predicate:agg-count", "type_urn": "urn:concordia:predicate-type:closure_language:v1", "parameters": {"expression": {"op": "==", "left": {"op": "count"}, "value": 2}}},
3+
"commitments": [
4+
{"commitment_id": "cc-r", "committer_did": "did:web:r.test", "mandate_proof_id": "mp-r", "commitment_terms": {"quantity": 50, "unit_price": 25, "delivery_date": "2026-05-20T00:00:00Z"}},
5+
{"commitment_id": "cc-w", "committer_did": "did:web:w.test", "mandate_proof_id": "mp-w", "commitment_terms": {"quantity": 50, "unit_price": 25, "delivery_date": "2026-05-21T00:00:00Z"}}
6+
],
7+
"expected_result": "satisfied"
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"predicate": {"predicate_id": "urn:concordia:predicate:agg-max", "type_urn": "urn:concordia:predicate-type:closure_language:v1", "parameters": {"expression": {"op": "==", "left": {"op": "max", "field": "commitment_terms.quantity"}, "value": 55}}},
3+
"commitments": [
4+
{"commitment_id": "cc-r", "committer_did": "did:web:r.test", "mandate_proof_id": "mp-r", "commitment_terms": {"quantity": 45, "unit_price": 25, "delivery_date": "2026-05-20T00:00:00Z"}},
5+
{"commitment_id": "cc-w", "committer_did": "did:web:w.test", "mandate_proof_id": "mp-w", "commitment_terms": {"quantity": 55, "unit_price": 25, "delivery_date": "2026-05-21T00:00:00Z"}}
6+
],
7+
"expected_result": "satisfied"
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"predicate": {"predicate_id": "urn:concordia:predicate:agg-min", "type_urn": "urn:concordia:predicate-type:closure_language:v1", "parameters": {"expression": {"op": "==", "left": {"op": "min", "field": "commitment_terms.quantity"}, "value": 45}}},
3+
"commitments": [
4+
{"commitment_id": "cc-r", "committer_did": "did:web:r.test", "mandate_proof_id": "mp-r", "commitment_terms": {"quantity": 45, "unit_price": 25, "delivery_date": "2026-05-20T00:00:00Z"}},
5+
{"commitment_id": "cc-w", "committer_did": "did:web:w.test", "mandate_proof_id": "mp-w", "commitment_terms": {"quantity": 55, "unit_price": 25, "delivery_date": "2026-05-21T00:00:00Z"}}
6+
],
7+
"expected_result": "satisfied"
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"predicate": {"predicate_id": "urn:concordia:predicate:agg-sum", "type_urn": "urn:concordia:predicate-type:closure_language:v1", "parameters": {"expression": {"op": "==", "left": {"op": "sum", "field": "commitment_terms.quantity"}, "value": 100}}},
3+
"commitments": [
4+
{"commitment_id": "cc-r", "committer_did": "did:web:r.test", "mandate_proof_id": "mp-r", "commitment_terms": {"quantity": 50, "unit_price": 25, "delivery_date": "2026-05-20T00:00:00Z"}},
5+
{"commitment_id": "cc-w", "committer_did": "did:web:w.test", "mandate_proof_id": "mp-w", "commitment_terms": {"quantity": 50, "unit_price": 25, "delivery_date": "2026-05-21T00:00:00Z"}}
6+
],
7+
"expected_result": "satisfied"
8+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"predicate": {
3+
"predicate_id": "urn:concordia:predicate:bilateral-happy",
4+
"type_urn": "urn:concordia:predicate-type:bilateral_chain_closure:v1",
5+
"parameters": {
6+
"expected_participants": ["did:web:r.test", "did:web:w.test"],
7+
"aggregate_quantity_required": 100,
8+
"match_tolerance": 0,
9+
"activation_deadline_iso": "2099-01-01T00:00:00Z",
10+
"mandate_check_required": true
11+
}
12+
},
13+
"commitments": [
14+
{"commitment_id": "cc-r", "committer_did": "did:web:r.test", "mandate_proof_id": "mp-r", "commitment_terms": {"quantity": 50, "unit_price": 25, "delivery_date": "2026-05-20T00:00:00Z"}},
15+
{"commitment_id": "cc-w", "committer_did": "did:web:w.test", "mandate_proof_id": "mp-w", "commitment_terms": {"quantity": 50, "unit_price": 25, "delivery_date": "2026-05-21T00:00:00Z"}}
16+
],
17+
"expected_result": "satisfied"
18+
}

0 commit comments

Comments
 (0)