wellmanifest-priority 0.1.0.dev0__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.
- priority.py +1598 -0
- wellmanifest_priority/__init__.py +85 -0
- wellmanifest_priority-0.1.0.dev0.dist-info/METADATA +124 -0
- wellmanifest_priority-0.1.0.dev0.dist-info/RECORD +7 -0
- wellmanifest_priority-0.1.0.dev0.dist-info/WHEEL +5 -0
- wellmanifest_priority-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- wellmanifest_priority-0.1.0.dev0.dist-info/top_level.txt +2 -0
priority.py
ADDED
|
@@ -0,0 +1,1598 @@
|
|
|
1
|
+
"""wellmanifest/priority — parse, evaluate, and project priority documents.
|
|
2
|
+
|
|
3
|
+
Propose-only: this module ranks and explains. It never edits a repository.
|
|
4
|
+
|
|
5
|
+
The document is deliberately two things at once. A priority states a standing
|
|
6
|
+
intent with a weight, and it also states, in advance, how that intent revises
|
|
7
|
+
itself when the environment moves. Ranking is then a pure function of the
|
|
8
|
+
document plus a set of signal readings, which is what makes a rank explainable:
|
|
9
|
+
every number can be traced back to a measurement that was named before it was
|
|
10
|
+
taken.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
SCHEMA = "wellmanifest.priority/v1"
|
|
28
|
+
READINGS_SCHEMA = "wellmanifest.priority/readings/v1"
|
|
29
|
+
CONTEXT_SCHEMA = "wellmanifest.priority/evaluation-context/v1"
|
|
30
|
+
RANKING_SCHEMA = "wellmanifest.priority/ranking/v1"
|
|
31
|
+
RANKING_SCHEMA_V2 = "wellmanifest.priority/ranking/v2"
|
|
32
|
+
EVALUATION_ATTESTATION_SCHEMA = "wellmanifest.priority/evaluation-attestation/v1"
|
|
33
|
+
EVALUATION_PREDICATE_TYPE = "https://wellmanifest.com/attestations/priority-evaluation/v1"
|
|
34
|
+
READINGS_COMPOSITION_SCHEMA = "wellmanifest.priority/readings-composition/v1"
|
|
35
|
+
|
|
36
|
+
TIERS = ("floor", "standard", "opportunistic")
|
|
37
|
+
#: Lexicographic bands. A lower index always outranks a higher one, whatever the
|
|
38
|
+
#: weights are; this is what makes "always highest" structural rather than a
|
|
39
|
+
#: large number that erodes as lesser work accumulates.
|
|
40
|
+
TIER_RANK = {name: index for index, name in enumerate(TIERS)}
|
|
41
|
+
|
|
42
|
+
SIGNAL_KINDS = ("metric", "event", "schedule")
|
|
43
|
+
NUMERIC_OPS = {
|
|
44
|
+
">": lambda a, b: a > b,
|
|
45
|
+
">=": lambda a, b: a >= b,
|
|
46
|
+
"<": lambda a, b: a < b,
|
|
47
|
+
"<=": lambda a, b: a <= b,
|
|
48
|
+
"==": lambda a, b: a == b,
|
|
49
|
+
"!=": lambda a, b: a != b,
|
|
50
|
+
}
|
|
51
|
+
EVENT_OPS = ("changed", "stale")
|
|
52
|
+
SCHEDULE_OPS = ("elapsed",)
|
|
53
|
+
RELATION_KINDS = ("complementary", "antagonistic", "neutral")
|
|
54
|
+
ABSENT_POLICIES = ("hold", "zero")
|
|
55
|
+
|
|
56
|
+
IDENTIFIER = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
|
57
|
+
SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$")
|
|
58
|
+
DURATION = re.compile(r"^([0-9]+)([smhdw])$")
|
|
59
|
+
_DURATION_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_duration(text: str) -> int:
|
|
63
|
+
"""Duration in seconds. Raises ValueError so a bad literal is a parse error."""
|
|
64
|
+
match = DURATION.match(text or "")
|
|
65
|
+
if not match:
|
|
66
|
+
raise ValueError(f"malformed duration: {text!r}")
|
|
67
|
+
return int(match.group(1)) * _DURATION_SECONDS[match.group(2)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def canonical_digest(value: Any) -> str:
|
|
71
|
+
"""Return the contract digest of a JSON-compatible value."""
|
|
72
|
+
encoded = json.dumps(
|
|
73
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
74
|
+
).encode("utf-8")
|
|
75
|
+
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def document_identity(document: Mapping[str, Any]) -> dict[str, str]:
|
|
79
|
+
"""Bind a derived artifact to the exact canonical priority document."""
|
|
80
|
+
return {
|
|
81
|
+
"id": str(document.get("id", "")),
|
|
82
|
+
"version": str(document.get("version", "")),
|
|
83
|
+
"digest": canonical_digest(document),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _timestamp(value: Any) -> datetime:
|
|
88
|
+
if not isinstance(value, str) or not value or len(value) > 40:
|
|
89
|
+
raise ValueError("invalid readings contract")
|
|
90
|
+
try:
|
|
91
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise ValueError("invalid readings contract") from exc
|
|
94
|
+
if parsed.tzinfo is None:
|
|
95
|
+
raise ValueError("invalid readings contract")
|
|
96
|
+
return parsed.astimezone(timezone.utc)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Finding:
|
|
101
|
+
code: str
|
|
102
|
+
message: str
|
|
103
|
+
path: str = "$"
|
|
104
|
+
|
|
105
|
+
def as_dict(self) -> dict[str, str]:
|
|
106
|
+
return {"code": self.code, "message": self.message, "path": self.path}
|
|
107
|
+
|
|
108
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
109
|
+
return f"{self.code} {self.path}: {self.message}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------
|
|
113
|
+
# parsing
|
|
114
|
+
# --------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
_QUOTED = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _unquote(value: str) -> str:
|
|
120
|
+
value = value.strip()
|
|
121
|
+
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
|
|
122
|
+
value = value[1:-1]
|
|
123
|
+
return value.replace('\\"', '"').replace("\\\\", "\\")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _quote(value: str) -> str:
|
|
127
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _split_keeping_quotes(rest: str) -> list[str]:
|
|
131
|
+
"""Split on whitespace, treating a quoted run as one token."""
|
|
132
|
+
tokens: list[str] = []
|
|
133
|
+
buffer = ""
|
|
134
|
+
in_quotes = False
|
|
135
|
+
escape = False
|
|
136
|
+
for char in rest:
|
|
137
|
+
if escape:
|
|
138
|
+
buffer += char
|
|
139
|
+
escape = False
|
|
140
|
+
continue
|
|
141
|
+
if char == "\\":
|
|
142
|
+
buffer += char
|
|
143
|
+
escape = True
|
|
144
|
+
continue
|
|
145
|
+
if char == '"':
|
|
146
|
+
in_quotes = not in_quotes
|
|
147
|
+
buffer += char
|
|
148
|
+
continue
|
|
149
|
+
if char.isspace() and not in_quotes:
|
|
150
|
+
if buffer:
|
|
151
|
+
tokens.append(buffer)
|
|
152
|
+
buffer = ""
|
|
153
|
+
continue
|
|
154
|
+
buffer += char
|
|
155
|
+
if buffer:
|
|
156
|
+
tokens.append(buffer)
|
|
157
|
+
return tokens
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _number(token: str) -> float:
|
|
161
|
+
return float(token)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse(text: str) -> dict[str, Any]:
|
|
165
|
+
"""Parse the text projection into the canonical JSON AST."""
|
|
166
|
+
document: dict[str, Any] = {
|
|
167
|
+
"schema": SCHEMA,
|
|
168
|
+
"signals": [],
|
|
169
|
+
"priorities": [],
|
|
170
|
+
"relations": [],
|
|
171
|
+
}
|
|
172
|
+
priority: dict[str, Any] | None = None
|
|
173
|
+
signal: dict[str, Any] | None = None
|
|
174
|
+
amend: dict[str, Any] | None = None
|
|
175
|
+
relation: dict[str, Any] | None = None
|
|
176
|
+
|
|
177
|
+
for raw in text.splitlines():
|
|
178
|
+
line = raw.split("#", 1)[0].rstrip() if not raw.strip().startswith("#") else ""
|
|
179
|
+
if not line.strip():
|
|
180
|
+
continue
|
|
181
|
+
indent = len(line) - len(line.lstrip())
|
|
182
|
+
head, _, rest = line.strip().partition(" ")
|
|
183
|
+
rest = rest.strip()
|
|
184
|
+
|
|
185
|
+
if indent == 0:
|
|
186
|
+
signal = amend = relation = None
|
|
187
|
+
if head == "DOCUMENT":
|
|
188
|
+
continue
|
|
189
|
+
if head == "SCHEMA":
|
|
190
|
+
document["schema"] = rest
|
|
191
|
+
elif head == "ID":
|
|
192
|
+
document["id"] = rest
|
|
193
|
+
elif head == "VERSION":
|
|
194
|
+
document["version"] = rest
|
|
195
|
+
elif head == "EFFECT":
|
|
196
|
+
document["effect"] = rest
|
|
197
|
+
elif head == "SIGNAL":
|
|
198
|
+
name, kind, producer = (_split_keeping_quotes(rest) + ["", "", ""])[:3]
|
|
199
|
+
signal = {"name": name, "kind": kind, "producer": _unquote(producer)}
|
|
200
|
+
document["signals"].append(signal)
|
|
201
|
+
priority = None
|
|
202
|
+
elif head == "PRIORITY":
|
|
203
|
+
priority = {"id": rest, "rules": [], "amendments": [], "touches": []}
|
|
204
|
+
document["priorities"].append(priority)
|
|
205
|
+
elif head == "RELATION":
|
|
206
|
+
tokens = _split_keeping_quotes(rest)
|
|
207
|
+
relation = {"a": tokens[0], "b": tokens[1], "kind": tokens[2]}
|
|
208
|
+
if len(tokens) > 3:
|
|
209
|
+
relation["strength"] = _number(tokens[3])
|
|
210
|
+
document["relations"].append(relation)
|
|
211
|
+
priority = None
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
if signal is not None and indent == 2:
|
|
215
|
+
if head == "UNIT":
|
|
216
|
+
signal["unit"] = _unquote(rest)
|
|
217
|
+
elif head == "WINDOW":
|
|
218
|
+
signal["window"] = rest
|
|
219
|
+
elif head == "ABSENT":
|
|
220
|
+
signal["absent"] = rest
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
if relation is not None and indent == 2 and head == "BECAUSE":
|
|
224
|
+
relation["because"] = _unquote(rest)
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
if priority is None:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if indent == 4 and amend is not None:
|
|
231
|
+
if head == "REWRITE":
|
|
232
|
+
what, _, value = rest.partition(" ")
|
|
233
|
+
if what == "INTENT":
|
|
234
|
+
amend["rewriteIntent"] = _unquote(value)
|
|
235
|
+
elif head == "SET":
|
|
236
|
+
what, _, value = rest.partition(" ")
|
|
237
|
+
if what == "TIER":
|
|
238
|
+
amend["setTier"] = value.strip()
|
|
239
|
+
elif what == "BASE":
|
|
240
|
+
amend["setBase"] = _number(value.strip())
|
|
241
|
+
elif head == "RETIRE":
|
|
242
|
+
amend["retire"] = True
|
|
243
|
+
elif head == "BECAUSE":
|
|
244
|
+
amend["because"] = _unquote(rest)
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
if indent == 2:
|
|
248
|
+
amend = None
|
|
249
|
+
if head == "TIER":
|
|
250
|
+
priority["tier"] = rest
|
|
251
|
+
elif head == "INTENT":
|
|
252
|
+
priority["intent"] = _unquote(rest)
|
|
253
|
+
elif head == "BECAUSE":
|
|
254
|
+
priority["because"] = _unquote(rest)
|
|
255
|
+
elif head == "BASE":
|
|
256
|
+
priority["base"] = _number(rest)
|
|
257
|
+
elif head == "TOUCHES":
|
|
258
|
+
priority["touches"].append(rest)
|
|
259
|
+
elif head == "SATISFIED_WHEN":
|
|
260
|
+
sig, op, value = _split_keeping_quotes(rest)[:3]
|
|
261
|
+
priority["satisfiedWhen"] = {"signal": sig, "op": op, "value": _number(value)}
|
|
262
|
+
elif head == "ON":
|
|
263
|
+
tokens = _split_keeping_quotes(rest)
|
|
264
|
+
rule = {
|
|
265
|
+
"signal": tokens[0],
|
|
266
|
+
"op": tokens[1],
|
|
267
|
+
"value": _number(tokens[2]),
|
|
268
|
+
"action": tokens[3],
|
|
269
|
+
"factor": _number(tokens[4]),
|
|
270
|
+
}
|
|
271
|
+
because = _QUOTED.search(rest)
|
|
272
|
+
if because and "BECAUSE" in rest:
|
|
273
|
+
rule["because"] = _unquote(rest.split("BECAUSE", 1)[1])
|
|
274
|
+
priority["rules"].append(rule)
|
|
275
|
+
elif head == "ESCALATE":
|
|
276
|
+
factor, _, per = rest.partition(" PER ")
|
|
277
|
+
priority["escalate"] = {"factor": _number(factor), "per": per.strip()}
|
|
278
|
+
elif head == "DECAY":
|
|
279
|
+
factor, _, per = rest.partition(" PER ")
|
|
280
|
+
priority["decay"] = {"factor": _number(factor), "per": per.strip()}
|
|
281
|
+
elif head == "STARVATION":
|
|
282
|
+
tokens = _split_keeping_quotes(rest)
|
|
283
|
+
entry = {"points": _number(tokens[0]), "per": tokens[2]}
|
|
284
|
+
if "CAP" in tokens:
|
|
285
|
+
entry["cap"] = _number(tokens[tokens.index("CAP") + 1])
|
|
286
|
+
priority["starvation"] = entry
|
|
287
|
+
elif head == "AMEND":
|
|
288
|
+
tokens = _split_keeping_quotes(rest)
|
|
289
|
+
# AMEND WHEN <signal> <op> <value> [FOR <duration>]
|
|
290
|
+
amend = {"signal": tokens[1], "op": tokens[2], "value": _number(tokens[3])}
|
|
291
|
+
if "FOR" in tokens:
|
|
292
|
+
amend["for"] = tokens[tokens.index("FOR") + 1]
|
|
293
|
+
priority["amendments"].append(amend)
|
|
294
|
+
return document
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def render(document: Mapping[str, Any]) -> str:
|
|
298
|
+
"""Render the AST back to the text projection."""
|
|
299
|
+
lines = ["DOCUMENT PRIORITY", f"SCHEMA {document.get('schema', SCHEMA)}"]
|
|
300
|
+
if document.get("id"):
|
|
301
|
+
lines.append(f"ID {document['id']}")
|
|
302
|
+
if document.get("version"):
|
|
303
|
+
lines.append(f"VERSION {document['version']}")
|
|
304
|
+
lines.append(f"EFFECT {document.get('effect', 'propose-only')}")
|
|
305
|
+
|
|
306
|
+
for signal in document.get("signals") or []:
|
|
307
|
+
lines.append("")
|
|
308
|
+
lines.append(f"SIGNAL {signal['name']} {signal['kind']} {_quote(signal.get('producer', ''))}")
|
|
309
|
+
if signal.get("unit"):
|
|
310
|
+
lines.append(f" UNIT {_quote(signal['unit'])}")
|
|
311
|
+
if signal.get("window"):
|
|
312
|
+
lines.append(f" WINDOW {signal['window']}")
|
|
313
|
+
if signal.get("absent"):
|
|
314
|
+
lines.append(f" ABSENT {signal['absent']}")
|
|
315
|
+
|
|
316
|
+
for item in document.get("priorities") or []:
|
|
317
|
+
lines.append("")
|
|
318
|
+
lines.append(f"PRIORITY {item['id']}")
|
|
319
|
+
lines.append(f" TIER {item.get('tier', 'standard')}")
|
|
320
|
+
if item.get("intent"):
|
|
321
|
+
lines.append(f" INTENT {_quote(item['intent'])}")
|
|
322
|
+
if item.get("because"):
|
|
323
|
+
lines.append(f" BECAUSE {_quote(item['because'])}")
|
|
324
|
+
if item.get("base") is not None:
|
|
325
|
+
lines.append(f" BASE {_fmt(item['base'])}")
|
|
326
|
+
for glob in item.get("touches") or []:
|
|
327
|
+
lines.append(f" TOUCHES {glob}")
|
|
328
|
+
satisfied = item.get("satisfiedWhen")
|
|
329
|
+
if satisfied:
|
|
330
|
+
lines.append(
|
|
331
|
+
f" SATISFIED_WHEN {satisfied['signal']} {satisfied['op']} {_fmt(satisfied['value'])}"
|
|
332
|
+
)
|
|
333
|
+
for rule in item.get("rules") or []:
|
|
334
|
+
because = f" BECAUSE {_quote(rule['because'])}" if rule.get("because") else ""
|
|
335
|
+
lines.append(
|
|
336
|
+
f" ON {rule['signal']} {rule['op']} {_fmt(rule['value'])} "
|
|
337
|
+
f"{rule['action']} {_fmt(rule['factor'])}{because}"
|
|
338
|
+
)
|
|
339
|
+
if item.get("escalate"):
|
|
340
|
+
lines.append(f" ESCALATE {_fmt(item['escalate']['factor'])} PER {item['escalate']['per']}")
|
|
341
|
+
if item.get("decay"):
|
|
342
|
+
lines.append(f" DECAY {_fmt(item['decay']['factor'])} PER {item['decay']['per']}")
|
|
343
|
+
starvation = item.get("starvation")
|
|
344
|
+
if starvation:
|
|
345
|
+
cap = f" CAP {_fmt(starvation['cap'])}" if starvation.get("cap") is not None else ""
|
|
346
|
+
lines.append(
|
|
347
|
+
f" STARVATION {_fmt(starvation['points'])} PER {starvation['per']}{cap}"
|
|
348
|
+
)
|
|
349
|
+
for amend in item.get("amendments") or []:
|
|
350
|
+
window = f" FOR {amend['for']}" if amend.get("for") else ""
|
|
351
|
+
lines.append(
|
|
352
|
+
f" AMEND WHEN {amend['signal']} {amend['op']} {_fmt(amend['value'])}{window}"
|
|
353
|
+
)
|
|
354
|
+
if amend.get("rewriteIntent"):
|
|
355
|
+
lines.append(f" REWRITE INTENT {_quote(amend['rewriteIntent'])}")
|
|
356
|
+
if amend.get("setTier"):
|
|
357
|
+
lines.append(f" SET TIER {amend['setTier']}")
|
|
358
|
+
if amend.get("setBase") is not None:
|
|
359
|
+
lines.append(f" SET BASE {_fmt(amend['setBase'])}")
|
|
360
|
+
if amend.get("retire"):
|
|
361
|
+
lines.append(" RETIRE")
|
|
362
|
+
if amend.get("because"):
|
|
363
|
+
lines.append(f" BECAUSE {_quote(amend['because'])}")
|
|
364
|
+
|
|
365
|
+
for relation in document.get("relations") or []:
|
|
366
|
+
lines.append("")
|
|
367
|
+
strength = f" {_fmt(relation['strength'])}" if relation.get("strength") is not None else ""
|
|
368
|
+
lines.append(f"RELATION {relation['a']} {relation['b']} {relation['kind']}{strength}")
|
|
369
|
+
if relation.get("because"):
|
|
370
|
+
lines.append(f" BECAUSE {_quote(relation['because'])}")
|
|
371
|
+
|
|
372
|
+
lines.append("")
|
|
373
|
+
return "\n".join(lines)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _fmt(value: float) -> str:
|
|
377
|
+
return str(int(value)) if float(value).is_integer() else str(value)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --------------------------------------------------------------------------
|
|
381
|
+
# validation
|
|
382
|
+
# --------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
DOCUMENT_KEYS = {"schema", "id", "version", "effect", "signals", "priorities", "relations"}
|
|
385
|
+
PRIORITY_KEYS = {
|
|
386
|
+
"id", "tier", "intent", "because", "base", "touches", "satisfiedWhen",
|
|
387
|
+
"rules", "escalate", "decay", "starvation", "amendments",
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def validate(document: Mapping[str, Any]) -> list[Finding]:
|
|
392
|
+
findings: list[Finding] = []
|
|
393
|
+
if document.get("schema") != SCHEMA:
|
|
394
|
+
findings.append(Finding("PRIORITY-KIND-001", f"schema must be {SCHEMA}"))
|
|
395
|
+
if document.get("effect", "propose-only") != "propose-only":
|
|
396
|
+
findings.append(Finding("PRIORITY-EFFECT-001", "effect model must be propose-only", "$.effect"))
|
|
397
|
+
if not IDENTIFIER.match(str(document.get("id", ""))):
|
|
398
|
+
findings.append(Finding("PRIORITY-KIND-001", "id must be a stable identifier", "$.id"))
|
|
399
|
+
if not SEMVER.match(str(document.get("version", ""))):
|
|
400
|
+
findings.append(Finding("PRIORITY-KIND-001", "version must be SemVer", "$.version"))
|
|
401
|
+
unknown = sorted(set(document) - DOCUMENT_KEYS)
|
|
402
|
+
if unknown:
|
|
403
|
+
findings.append(Finding("PRIORITY-KIND-001", f"unknown fields {unknown} (unknownPolicy=reject)"))
|
|
404
|
+
|
|
405
|
+
signals: dict[str, Mapping[str, Any]] = {}
|
|
406
|
+
for index, signal in enumerate(document.get("signals") or []):
|
|
407
|
+
where = f"$.signals[{index}]"
|
|
408
|
+
name = signal.get("name")
|
|
409
|
+
if not IDENTIFIER.match(str(name or "")):
|
|
410
|
+
findings.append(Finding("PRIORITY-SIGNAL-001", "signal name must be an identifier", where))
|
|
411
|
+
if signal.get("kind") not in SIGNAL_KINDS:
|
|
412
|
+
findings.append(Finding("PRIORITY-SIGNAL-001", f"unknown signal kind {signal.get('kind')!r}", where))
|
|
413
|
+
if not str(signal.get("producer") or "").strip():
|
|
414
|
+
findings.append(
|
|
415
|
+
Finding(
|
|
416
|
+
"PRIORITY-SIGNAL-002",
|
|
417
|
+
"a signal must name its producer; an unsourced number cannot justify a rank",
|
|
418
|
+
where,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
if signal.get("absent") and signal["absent"] not in ABSENT_POLICIES:
|
|
422
|
+
findings.append(Finding("PRIORITY-SIGNAL-001", "absent policy must be hold or zero", where))
|
|
423
|
+
if signal.get("window"):
|
|
424
|
+
try:
|
|
425
|
+
parse_duration(signal["window"])
|
|
426
|
+
except ValueError as error:
|
|
427
|
+
findings.append(Finding("PRIORITY-SIGNAL-001", str(error), where))
|
|
428
|
+
if name in signals:
|
|
429
|
+
findings.append(Finding("PRIORITY-SIGNAL-001", f"duplicate signal {name!r}", where))
|
|
430
|
+
signals[name] = signal
|
|
431
|
+
|
|
432
|
+
ids: set[str] = set()
|
|
433
|
+
for index, item in enumerate(document.get("priorities") or []):
|
|
434
|
+
where = f"$.priorities[{index}]"
|
|
435
|
+
item_id = item.get("id")
|
|
436
|
+
if not IDENTIFIER.match(str(item_id or "")):
|
|
437
|
+
findings.append(Finding("PRIORITY-KIND-001", "priority id must be an identifier", where))
|
|
438
|
+
if item_id in ids:
|
|
439
|
+
findings.append(Finding("PRIORITY-KIND-001", f"duplicate priority {item_id!r}", where))
|
|
440
|
+
ids.add(item_id)
|
|
441
|
+
unknown = sorted(set(item) - PRIORITY_KEYS)
|
|
442
|
+
if unknown:
|
|
443
|
+
findings.append(Finding("PRIORITY-KIND-001", f"unknown priority fields {unknown}", where))
|
|
444
|
+
|
|
445
|
+
tier = item.get("tier")
|
|
446
|
+
if tier not in TIERS:
|
|
447
|
+
findings.append(Finding("PRIORITY-TIER-001", f"unknown tier {tier!r}", f"{where}.tier"))
|
|
448
|
+
if not str(item.get("intent") or "").strip():
|
|
449
|
+
findings.append(Finding("PRIORITY-INTENT-001", "intent is required", f"{where}.intent"))
|
|
450
|
+
if not str(item.get("because") or "").strip():
|
|
451
|
+
findings.append(
|
|
452
|
+
Finding(
|
|
453
|
+
"PRIORITY-INTENT-002",
|
|
454
|
+
"because is required: a priority without stated evidence is a preference",
|
|
455
|
+
f"{where}.because",
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
base = item.get("base")
|
|
459
|
+
if not isinstance(base, (int, float)) or base <= 0:
|
|
460
|
+
findings.append(Finding("PRIORITY-WEIGHT-001", "base must be a positive number", f"{where}.base"))
|
|
461
|
+
|
|
462
|
+
if tier == "floor" and item.get("decay"):
|
|
463
|
+
findings.append(
|
|
464
|
+
Finding(
|
|
465
|
+
"PRIORITY-TIER-002",
|
|
466
|
+
"a floor priority must not decay: an unmet guarantee does not become acceptable with age",
|
|
467
|
+
f"{where}.decay",
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
if tier == "opportunistic" and item.get("escalate"):
|
|
471
|
+
findings.append(
|
|
472
|
+
Finding("PRIORITY-TIER-002", "an opportunistic priority must not escalate", f"{where}.escalate")
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
satisfied = item.get("satisfiedWhen")
|
|
476
|
+
if not satisfied:
|
|
477
|
+
findings.append(
|
|
478
|
+
Finding(
|
|
479
|
+
"PRIORITY-INTENT-003",
|
|
480
|
+
"satisfiedWhen is required: a priority with no completion test can never leave the list",
|
|
481
|
+
f"{where}.satisfiedWhen",
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
findings.extend(_condition_findings(satisfied, signals, f"{where}.satisfiedWhen"))
|
|
486
|
+
|
|
487
|
+
for rule_index, rule in enumerate(item.get("rules") or []):
|
|
488
|
+
rule_where = f"{where}.rules[{rule_index}]"
|
|
489
|
+
findings.extend(_condition_findings(rule, signals, rule_where))
|
|
490
|
+
if rule.get("action") not in {"RAISE", "LOWER"}:
|
|
491
|
+
findings.append(Finding("PRIORITY-RULE-001", "action must be RAISE or LOWER", rule_where))
|
|
492
|
+
factor = rule.get("factor")
|
|
493
|
+
if not isinstance(factor, (int, float)) or factor <= 0:
|
|
494
|
+
findings.append(Finding("PRIORITY-RULE-001", "factor must be positive", rule_where))
|
|
495
|
+
elif rule.get("action") == "RAISE" and factor < 1:
|
|
496
|
+
findings.append(Finding("PRIORITY-RULE-002", "RAISE factor below 1 lowers the weight", rule_where))
|
|
497
|
+
elif rule.get("action") == "LOWER" and factor > 1:
|
|
498
|
+
findings.append(Finding("PRIORITY-RULE-002", "LOWER factor above 1 raises the weight", rule_where))
|
|
499
|
+
if not str(rule.get("because") or "").strip():
|
|
500
|
+
findings.append(
|
|
501
|
+
Finding("PRIORITY-RULE-003", "a weight rule must say why it fires", rule_where)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
for amend_index, amend in enumerate(item.get("amendments") or []):
|
|
505
|
+
amend_where = f"{where}.amendments[{amend_index}]"
|
|
506
|
+
findings.extend(_condition_findings(amend, signals, amend_where))
|
|
507
|
+
effects = [key for key in ("rewriteIntent", "setTier", "setBase", "retire") if amend.get(key)]
|
|
508
|
+
if not effects:
|
|
509
|
+
findings.append(Finding("PRIORITY-AMEND-001", "an amendment must change something", amend_where))
|
|
510
|
+
if amend.get("setTier") and amend["setTier"] not in TIERS:
|
|
511
|
+
findings.append(Finding("PRIORITY-AMEND-001", "unknown tier in amendment", amend_where))
|
|
512
|
+
if not str(amend.get("because") or "").strip():
|
|
513
|
+
findings.append(
|
|
514
|
+
Finding(
|
|
515
|
+
"PRIORITY-AMEND-002",
|
|
516
|
+
"an amendment rewrites the intent itself and must justify that",
|
|
517
|
+
amend_where,
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
if amend.get("for"):
|
|
521
|
+
try:
|
|
522
|
+
parse_duration(amend["for"])
|
|
523
|
+
except ValueError as error:
|
|
524
|
+
findings.append(Finding("PRIORITY-AMEND-001", str(error), amend_where))
|
|
525
|
+
|
|
526
|
+
for index, relation in enumerate(document.get("relations") or []):
|
|
527
|
+
where = f"$.relations[{index}]"
|
|
528
|
+
if relation.get("kind") not in RELATION_KINDS:
|
|
529
|
+
findings.append(Finding("PRIORITY-RELATION-001", f"unknown relation {relation.get('kind')!r}", where))
|
|
530
|
+
for side in ("a", "b"):
|
|
531
|
+
if relation.get(side) not in ids:
|
|
532
|
+
findings.append(
|
|
533
|
+
Finding("PRIORITY-RELATION-001", f"relation names unknown priority {relation.get(side)!r}", where)
|
|
534
|
+
)
|
|
535
|
+
if relation.get("a") == relation.get("b"):
|
|
536
|
+
findings.append(Finding("PRIORITY-RELATION-001", "a priority cannot relate to itself", where))
|
|
537
|
+
strength = relation.get("strength")
|
|
538
|
+
if strength is not None and not -1.0 <= float(strength) <= 1.0:
|
|
539
|
+
findings.append(Finding("PRIORITY-RELATION-001", "strength must lie in [-1, 1]", where))
|
|
540
|
+
|
|
541
|
+
return findings
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _condition_findings(
|
|
545
|
+
condition: Mapping[str, Any], signals: Mapping[str, Mapping[str, Any]], where: str
|
|
546
|
+
) -> list[Finding]:
|
|
547
|
+
name = condition.get("signal")
|
|
548
|
+
if name not in signals:
|
|
549
|
+
return [
|
|
550
|
+
Finding(
|
|
551
|
+
"PRIORITY-SIGNAL-003",
|
|
552
|
+
f"condition uses undeclared signal {name!r}; declare it with its producer",
|
|
553
|
+
where,
|
|
554
|
+
)
|
|
555
|
+
]
|
|
556
|
+
kind = signals[name].get("kind")
|
|
557
|
+
op = condition.get("op")
|
|
558
|
+
allowed = NUMERIC_OPS if kind == "metric" else (EVENT_OPS if kind == "event" else SCHEDULE_OPS)
|
|
559
|
+
if op not in allowed:
|
|
560
|
+
return [Finding("PRIORITY-RULE-001", f"operator {op!r} is not valid for a {kind} signal", where)]
|
|
561
|
+
return []
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# --------------------------------------------------------------------------
|
|
565
|
+
# evaluation
|
|
566
|
+
# --------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@dataclass
|
|
570
|
+
class Reading:
|
|
571
|
+
"""One signal observation."""
|
|
572
|
+
|
|
573
|
+
value: float | None = None
|
|
574
|
+
# How long the observed condition/event/schedule has been active. This is
|
|
575
|
+
# semantic input for FOR/stale/elapsed and is independent of evidence age.
|
|
576
|
+
age_seconds: float = 0.0
|
|
577
|
+
# How old the observation itself is. SIGNAL WINDOW applies only here.
|
|
578
|
+
observed_age_seconds: float = 0.0
|
|
579
|
+
changed: bool = False
|
|
580
|
+
observed_at: str | None = None
|
|
581
|
+
active_since: str | None = None
|
|
582
|
+
producer_ref: str | None = None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@dataclass(frozen=True)
|
|
586
|
+
class ReadingsEnvelope:
|
|
587
|
+
"""Validated observations bound to one document and source revision."""
|
|
588
|
+
|
|
589
|
+
observed_at: str
|
|
590
|
+
revision: str
|
|
591
|
+
readings: Mapping[str, Reading]
|
|
592
|
+
payload: Mapping[str, Any]
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def digest(self) -> str:
|
|
596
|
+
return canonical_digest(self.payload)
|
|
597
|
+
|
|
598
|
+
def receipt_ref(self) -> dict[str, str]:
|
|
599
|
+
return {
|
|
600
|
+
"digest": self.digest,
|
|
601
|
+
"observedAt": self.observed_at,
|
|
602
|
+
"revision": self.revision,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def load_readings(
|
|
607
|
+
document: Mapping[str, Any], payload: Any
|
|
608
|
+
) -> ReadingsEnvelope:
|
|
609
|
+
"""Validate and normalize a revision-bound readings/v1 document.
|
|
610
|
+
|
|
611
|
+
Producers run outside this library. The evaluator accepts their typed,
|
|
612
|
+
digest-bound observations and rejects unknown signals, producer drift,
|
|
613
|
+
future timestamps, non-finite numbers, and a mismatched document binding.
|
|
614
|
+
"""
|
|
615
|
+
if not isinstance(payload, Mapping) or set(payload) != {
|
|
616
|
+
"schema", "document", "observedAt", "revision", "readings"
|
|
617
|
+
}:
|
|
618
|
+
raise ValueError("invalid readings contract")
|
|
619
|
+
if payload.get("schema") != READINGS_SCHEMA:
|
|
620
|
+
raise ValueError("invalid readings contract")
|
|
621
|
+
|
|
622
|
+
subject = payload.get("document")
|
|
623
|
+
if not isinstance(subject, Mapping) or set(subject) != {"id", "version", "digest"}:
|
|
624
|
+
raise ValueError("invalid readings contract")
|
|
625
|
+
if dict(subject) != document_identity(document):
|
|
626
|
+
raise ValueError("readings document binding mismatch")
|
|
627
|
+
|
|
628
|
+
revision = payload.get("revision")
|
|
629
|
+
if not isinstance(revision, str) or not revision or len(revision) > 240:
|
|
630
|
+
raise ValueError("invalid readings contract")
|
|
631
|
+
envelope_time = _timestamp(payload.get("observedAt"))
|
|
632
|
+
signals = {item["name"]: item for item in document.get("signals") or []}
|
|
633
|
+
raw_readings = payload.get("readings")
|
|
634
|
+
if not isinstance(raw_readings, Mapping):
|
|
635
|
+
raise ValueError("invalid readings contract")
|
|
636
|
+
|
|
637
|
+
normalized_payload = {
|
|
638
|
+
"schema": READINGS_SCHEMA,
|
|
639
|
+
"document": dict(subject),
|
|
640
|
+
"observedAt": payload["observedAt"],
|
|
641
|
+
"revision": revision,
|
|
642
|
+
"readings": {},
|
|
643
|
+
}
|
|
644
|
+
readings: dict[str, Reading] = {}
|
|
645
|
+
for name, raw in sorted(raw_readings.items()):
|
|
646
|
+
if name not in signals or not isinstance(raw, Mapping):
|
|
647
|
+
raise ValueError("invalid readings contract")
|
|
648
|
+
if not {"observedAt", "producerRef"} <= set(raw) <= {
|
|
649
|
+
"observedAt", "activeSince", "producerRef", "value", "changed"
|
|
650
|
+
}:
|
|
651
|
+
raise ValueError("invalid readings contract")
|
|
652
|
+
producer = raw.get("producerRef")
|
|
653
|
+
if producer != signals[name].get("producer"):
|
|
654
|
+
raise ValueError("readings producer binding mismatch")
|
|
655
|
+
observed_time = _timestamp(raw.get("observedAt"))
|
|
656
|
+
if observed_time > envelope_time:
|
|
657
|
+
raise ValueError("invalid readings contract")
|
|
658
|
+
active_since = raw.get("activeSince")
|
|
659
|
+
active_time = _timestamp(active_since) if active_since is not None else None
|
|
660
|
+
if active_time is not None and active_time > observed_time:
|
|
661
|
+
raise ValueError("invalid readings contract")
|
|
662
|
+
value = raw.get("value")
|
|
663
|
+
if value is not None:
|
|
664
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
665
|
+
raise ValueError("invalid readings contract")
|
|
666
|
+
value = float(value)
|
|
667
|
+
if not math.isfinite(value):
|
|
668
|
+
raise ValueError("invalid readings contract")
|
|
669
|
+
changed = raw.get("changed", False)
|
|
670
|
+
if not isinstance(changed, bool):
|
|
671
|
+
raise ValueError("invalid readings contract")
|
|
672
|
+
age = max(0.0, (envelope_time - observed_time).total_seconds())
|
|
673
|
+
normalized_item: dict[str, Any] = {
|
|
674
|
+
"observedAt": raw["observedAt"],
|
|
675
|
+
"producerRef": producer,
|
|
676
|
+
}
|
|
677
|
+
if active_since is not None:
|
|
678
|
+
normalized_item["activeSince"] = active_since
|
|
679
|
+
if "value" in raw:
|
|
680
|
+
normalized_item["value"] = value
|
|
681
|
+
if "changed" in raw:
|
|
682
|
+
normalized_item["changed"] = changed
|
|
683
|
+
normalized_payload["readings"][name] = normalized_item
|
|
684
|
+
readings[name] = Reading(
|
|
685
|
+
value=value,
|
|
686
|
+
age_seconds=(
|
|
687
|
+
max(0.0, (envelope_time - active_time).total_seconds())
|
|
688
|
+
if active_time is not None
|
|
689
|
+
else 0.0
|
|
690
|
+
),
|
|
691
|
+
observed_age_seconds=age,
|
|
692
|
+
changed=changed,
|
|
693
|
+
observed_at=raw["observedAt"],
|
|
694
|
+
active_since=active_since,
|
|
695
|
+
producer_ref=producer,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
return ReadingsEnvelope(
|
|
699
|
+
observed_at=str(payload["observedAt"]),
|
|
700
|
+
revision=revision,
|
|
701
|
+
readings=readings,
|
|
702
|
+
payload=normalized_payload,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@dataclass(frozen=True)
|
|
707
|
+
class EvaluationContext:
|
|
708
|
+
"""Validated, reproducible time inputs for one evaluation run."""
|
|
709
|
+
|
|
710
|
+
observed_at: str
|
|
711
|
+
revision: str
|
|
712
|
+
ages: Mapping[str, float]
|
|
713
|
+
idle: Mapping[str, float]
|
|
714
|
+
payload: Mapping[str, Any]
|
|
715
|
+
|
|
716
|
+
@property
|
|
717
|
+
def digest(self) -> str:
|
|
718
|
+
return canonical_digest(self.payload)
|
|
719
|
+
|
|
720
|
+
def receipt_ref(self) -> dict[str, str]:
|
|
721
|
+
return {
|
|
722
|
+
"digest": self.digest,
|
|
723
|
+
"observedAt": self.observed_at,
|
|
724
|
+
"revision": self.revision,
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _duration_map(raw: Any, priority_ids: set[str]) -> dict[str, float]:
|
|
729
|
+
if not isinstance(raw, Mapping):
|
|
730
|
+
raise ValueError("invalid evaluation context")
|
|
731
|
+
if not all(isinstance(identifier, str) for identifier in raw):
|
|
732
|
+
raise ValueError("invalid evaluation context")
|
|
733
|
+
normalized: dict[str, float] = {}
|
|
734
|
+
for identifier in sorted(raw):
|
|
735
|
+
value = raw[identifier]
|
|
736
|
+
if identifier not in priority_ids:
|
|
737
|
+
raise ValueError("evaluation context priority mismatch")
|
|
738
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
739
|
+
raise ValueError("invalid evaluation context")
|
|
740
|
+
duration = float(value)
|
|
741
|
+
if not math.isfinite(duration) or duration < 0:
|
|
742
|
+
raise ValueError("invalid evaluation context")
|
|
743
|
+
normalized[identifier] = duration
|
|
744
|
+
return normalized
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def load_evaluation_context(
|
|
748
|
+
document: Mapping[str, Any],
|
|
749
|
+
payload: Any,
|
|
750
|
+
readings: ReadingsEnvelope | None = None,
|
|
751
|
+
) -> EvaluationContext:
|
|
752
|
+
"""Validate ages and idle durations bound to one document/readings run."""
|
|
753
|
+
if not isinstance(payload, Mapping) or set(payload) != {
|
|
754
|
+
"schema", "document", "readings", "observedAt", "revision", "ages", "idle"
|
|
755
|
+
}:
|
|
756
|
+
raise ValueError("invalid evaluation context")
|
|
757
|
+
if payload.get("schema") != CONTEXT_SCHEMA:
|
|
758
|
+
raise ValueError("invalid evaluation context")
|
|
759
|
+
if payload.get("document") != document_identity(document):
|
|
760
|
+
raise ValueError("evaluation context document binding mismatch")
|
|
761
|
+
|
|
762
|
+
expected_readings = readings.receipt_ref() if readings is not None else None
|
|
763
|
+
if payload.get("readings") != expected_readings:
|
|
764
|
+
raise ValueError("evaluation context readings binding mismatch")
|
|
765
|
+
observed_at = payload.get("observedAt")
|
|
766
|
+
observed_time = _timestamp(observed_at)
|
|
767
|
+
if readings is not None and observed_time < _timestamp(readings.observed_at):
|
|
768
|
+
raise ValueError("invalid evaluation context")
|
|
769
|
+
revision = payload.get("revision")
|
|
770
|
+
if not isinstance(revision, str) or not revision or len(revision) > 240:
|
|
771
|
+
raise ValueError("invalid evaluation context")
|
|
772
|
+
|
|
773
|
+
priority_ids = {str(item["id"]) for item in document.get("priorities") or []}
|
|
774
|
+
if not priority_ids:
|
|
775
|
+
raise ValueError("invalid evaluation context")
|
|
776
|
+
ages = _duration_map(payload.get("ages"), priority_ids)
|
|
777
|
+
idle = _duration_map(payload.get("idle"), priority_ids)
|
|
778
|
+
normalized_payload = {
|
|
779
|
+
"schema": CONTEXT_SCHEMA,
|
|
780
|
+
"document": document_identity(document),
|
|
781
|
+
"readings": expected_readings,
|
|
782
|
+
"observedAt": observed_at,
|
|
783
|
+
"revision": revision,
|
|
784
|
+
"ages": ages,
|
|
785
|
+
"idle": idle,
|
|
786
|
+
}
|
|
787
|
+
return EvaluationContext(
|
|
788
|
+
observed_at=str(observed_at),
|
|
789
|
+
revision=revision,
|
|
790
|
+
ages=ages,
|
|
791
|
+
idle=idle,
|
|
792
|
+
payload=normalized_payload,
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def compose_readings(
|
|
797
|
+
document: Mapping[str, Any],
|
|
798
|
+
sources: Mapping[str, ReadingsEnvelope],
|
|
799
|
+
*,
|
|
800
|
+
observed_at: str,
|
|
801
|
+
revision: str,
|
|
802
|
+
) -> tuple[ReadingsEnvelope, dict[str, Any]]:
|
|
803
|
+
"""Compose disjoint producer envelopes and bind their provenance."""
|
|
804
|
+
if not isinstance(sources, Mapping) or not sources:
|
|
805
|
+
raise ValueError("invalid readings composition")
|
|
806
|
+
if not isinstance(revision, str) or not revision or len(revision) > 240:
|
|
807
|
+
raise ValueError("invalid readings composition")
|
|
808
|
+
composition_time = _timestamp(observed_at)
|
|
809
|
+
merged: dict[str, Any] = {}
|
|
810
|
+
source_refs: list[dict[str, Any]] = []
|
|
811
|
+
for source_id in sorted(sources):
|
|
812
|
+
if not isinstance(source_id, str) or not IDENTIFIER.fullmatch(source_id):
|
|
813
|
+
raise ValueError("invalid readings composition source")
|
|
814
|
+
source = sources[source_id]
|
|
815
|
+
if not isinstance(source, ReadingsEnvelope):
|
|
816
|
+
raise ValueError("invalid readings composition source")
|
|
817
|
+
bound = load_readings(document, source.payload)
|
|
818
|
+
if _timestamp(bound.observed_at) > composition_time:
|
|
819
|
+
raise ValueError("readings composition source is from the future")
|
|
820
|
+
overlap = set(merged) & set(bound.payload["readings"])
|
|
821
|
+
if overlap:
|
|
822
|
+
raise ValueError("readings composition has duplicate signals")
|
|
823
|
+
merged.update(bound.payload["readings"])
|
|
824
|
+
source_refs.append({"id": source_id, **bound.receipt_ref()})
|
|
825
|
+
|
|
826
|
+
output_payload = {
|
|
827
|
+
"schema": READINGS_SCHEMA,
|
|
828
|
+
"document": document_identity(document),
|
|
829
|
+
"observedAt": observed_at,
|
|
830
|
+
"revision": revision,
|
|
831
|
+
"readings": merged,
|
|
832
|
+
}
|
|
833
|
+
output = load_readings(document, output_payload)
|
|
834
|
+
receipt: dict[str, Any] = {
|
|
835
|
+
"schema": READINGS_COMPOSITION_SCHEMA,
|
|
836
|
+
"document": document_identity(document),
|
|
837
|
+
"sources": source_refs,
|
|
838
|
+
"output": output.receipt_ref(),
|
|
839
|
+
"executionAuthorized": False,
|
|
840
|
+
}
|
|
841
|
+
receipt["receiptDigest"] = canonical_digest(receipt)
|
|
842
|
+
return output, receipt
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
@dataclass
|
|
846
|
+
class Evaluated:
|
|
847
|
+
id: str
|
|
848
|
+
tier: str
|
|
849
|
+
intent: str
|
|
850
|
+
base: float
|
|
851
|
+
effective: float
|
|
852
|
+
satisfied: bool
|
|
853
|
+
explanation: list[str] = field(default_factory=list)
|
|
854
|
+
amended: list[str] = field(default_factory=list)
|
|
855
|
+
retired: bool = False
|
|
856
|
+
|
|
857
|
+
@property
|
|
858
|
+
def rank_key(self) -> tuple[int, float]:
|
|
859
|
+
"""Tier first, weight second. Weight can never cross a tier boundary."""
|
|
860
|
+
return (TIER_RANK.get(self.tier, len(TIERS)), -self.effective)
|
|
861
|
+
|
|
862
|
+
def as_dict(self) -> dict[str, Any]:
|
|
863
|
+
return {
|
|
864
|
+
"id": self.id,
|
|
865
|
+
"tier": self.tier,
|
|
866
|
+
"intent": self.intent,
|
|
867
|
+
"base": self.base,
|
|
868
|
+
"effective": round(self.effective, 4),
|
|
869
|
+
"satisfied": self.satisfied,
|
|
870
|
+
"retired": self.retired,
|
|
871
|
+
"explanation": self.explanation,
|
|
872
|
+
"amended": self.amended,
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def ranking_receipt(
|
|
877
|
+
document: Mapping[str, Any],
|
|
878
|
+
evaluated: Sequence[Evaluated],
|
|
879
|
+
readings: ReadingsEnvelope | None = None,
|
|
880
|
+
) -> dict[str, Any]:
|
|
881
|
+
"""Build a deterministic receipt that cannot authorize execution."""
|
|
882
|
+
receipt: dict[str, Any] = {
|
|
883
|
+
"schema": RANKING_SCHEMA,
|
|
884
|
+
"document": document_identity(document),
|
|
885
|
+
"readings": readings.receipt_ref() if readings is not None else None,
|
|
886
|
+
"priorities": [item.as_dict() for item in evaluated],
|
|
887
|
+
"executionAuthorized": False,
|
|
888
|
+
}
|
|
889
|
+
receipt["receiptDigest"] = canonical_digest(receipt)
|
|
890
|
+
return receipt
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def ranking_receipt_v2(
|
|
894
|
+
document: Mapping[str, Any],
|
|
895
|
+
context: EvaluationContext,
|
|
896
|
+
readings: ReadingsEnvelope | None = None,
|
|
897
|
+
) -> dict[str, Any]:
|
|
898
|
+
"""Build a receipt that binds every input affecting deterministic rank."""
|
|
899
|
+
bound_readings = load_readings(document, readings.payload) if readings is not None else None
|
|
900
|
+
bound_context = load_evaluation_context(document, context.payload, bound_readings)
|
|
901
|
+
evaluated = evaluate(
|
|
902
|
+
document,
|
|
903
|
+
bound_readings.readings if bound_readings is not None else None,
|
|
904
|
+
ages=bound_context.ages,
|
|
905
|
+
idle=bound_context.idle,
|
|
906
|
+
)
|
|
907
|
+
receipt: dict[str, Any] = {
|
|
908
|
+
"schema": RANKING_SCHEMA_V2,
|
|
909
|
+
"document": document_identity(document),
|
|
910
|
+
"readings": bound_readings.receipt_ref() if bound_readings is not None else None,
|
|
911
|
+
"context": bound_context.receipt_ref(),
|
|
912
|
+
"priorities": [item.as_dict() for item in evaluated],
|
|
913
|
+
"executionAuthorized": False,
|
|
914
|
+
}
|
|
915
|
+
receipt["receiptDigest"] = canonical_digest(receipt)
|
|
916
|
+
return receipt
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
@dataclass(frozen=True)
|
|
920
|
+
class VerifiedEvaluation:
|
|
921
|
+
"""Exact, current evaluation accepted through an external trust boundary."""
|
|
922
|
+
|
|
923
|
+
attestation_id: str
|
|
924
|
+
issuer: str
|
|
925
|
+
nonce: str
|
|
926
|
+
audience: str
|
|
927
|
+
payload_digest: str
|
|
928
|
+
ranking_digest: str
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def attestation_signing_bytes(attestation: Mapping[str, Any]) -> bytes:
|
|
932
|
+
"""Canonical bytes covered by an evaluation attestation signature."""
|
|
933
|
+
if not isinstance(attestation, Mapping) or "signature" not in attestation:
|
|
934
|
+
raise ValueError("invalid evaluation attestation")
|
|
935
|
+
unsigned = {key: value for key, value in attestation.items() if key != "signature"}
|
|
936
|
+
return json.dumps(
|
|
937
|
+
unsigned, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
938
|
+
).encode("utf-8")
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def verify_evaluation_attestation(
|
|
942
|
+
document: Mapping[str, Any],
|
|
943
|
+
context: EvaluationContext,
|
|
944
|
+
receipt: Mapping[str, Any],
|
|
945
|
+
attestation: Mapping[str, Any],
|
|
946
|
+
*,
|
|
947
|
+
readings: ReadingsEnvelope | None = None,
|
|
948
|
+
trusted_issuers: Mapping[str, Iterable[str]],
|
|
949
|
+
expected_audience: str,
|
|
950
|
+
at: datetime,
|
|
951
|
+
consumed_nonces: Iterable[str],
|
|
952
|
+
signature_verifier: Callable[[bytes, Mapping[str, Any]], bool],
|
|
953
|
+
) -> VerifiedEvaluation:
|
|
954
|
+
"""Verify exact bindings and delegate signature trust to a protected caller.
|
|
955
|
+
|
|
956
|
+
This function does not own keys or issuer policy. ``signature_verifier``
|
|
957
|
+
must cross a separately controlled protected boundary; repository code
|
|
958
|
+
returning ``True`` is not trust evidence. Nonce consumption remains an
|
|
959
|
+
atomic responsibility of the caller after this function succeeds.
|
|
960
|
+
"""
|
|
961
|
+
top_fields = {
|
|
962
|
+
"schema", "predicateType", "attestationId", "issuer", "subject",
|
|
963
|
+
"validity", "antiReplay", "signature", "executionAuthorized",
|
|
964
|
+
}
|
|
965
|
+
if not isinstance(attestation, Mapping) or set(attestation) != top_fields:
|
|
966
|
+
raise ValueError("invalid evaluation attestation")
|
|
967
|
+
if attestation.get("schema") != EVALUATION_ATTESTATION_SCHEMA:
|
|
968
|
+
raise ValueError("invalid evaluation attestation")
|
|
969
|
+
if attestation.get("predicateType") != EVALUATION_PREDICATE_TYPE:
|
|
970
|
+
raise ValueError("invalid evaluation attestation predicate")
|
|
971
|
+
if attestation.get("executionAuthorized") is not False:
|
|
972
|
+
raise ValueError("evaluation attestation cannot authorize execution")
|
|
973
|
+
|
|
974
|
+
attestation_id = attestation.get("attestationId")
|
|
975
|
+
if not isinstance(attestation_id, str) or not re.fullmatch(
|
|
976
|
+
r"priority-attestation://[a-z0-9][a-z0-9._:/-]{2,200}", attestation_id
|
|
977
|
+
):
|
|
978
|
+
raise ValueError("invalid evaluation attestation id")
|
|
979
|
+
issuer = attestation.get("issuer")
|
|
980
|
+
if not isinstance(issuer, Mapping) or set(issuer) != {"id", "implementationDigest"}:
|
|
981
|
+
raise ValueError("invalid evaluation attestation issuer")
|
|
982
|
+
issuer_id = issuer.get("id")
|
|
983
|
+
implementation_digest = issuer.get("implementationDigest")
|
|
984
|
+
if not isinstance(issuer_id, str) or not re.fullmatch(
|
|
985
|
+
r"priority-evaluator://[a-z0-9][a-z0-9._/-]{2,200}", issuer_id
|
|
986
|
+
):
|
|
987
|
+
raise ValueError("invalid evaluation attestation issuer")
|
|
988
|
+
if not isinstance(implementation_digest, str) or not re.fullmatch(
|
|
989
|
+
r"sha256:[a-f0-9]{64}", implementation_digest
|
|
990
|
+
):
|
|
991
|
+
raise ValueError("invalid evaluation attestation issuer")
|
|
992
|
+
trusted_digests = set(trusted_issuers.get(issuer_id, ()))
|
|
993
|
+
if implementation_digest not in trusted_digests:
|
|
994
|
+
raise ValueError("untrusted evaluation attestation issuer")
|
|
995
|
+
|
|
996
|
+
bound_readings = load_readings(document, readings.payload) if readings is not None else None
|
|
997
|
+
bound_context = load_evaluation_context(document, context.payload, bound_readings)
|
|
998
|
+
expected_receipt = ranking_receipt_v2(document, bound_context, bound_readings)
|
|
999
|
+
if dict(receipt) != expected_receipt:
|
|
1000
|
+
raise ValueError("evaluation attestation ranking mismatch")
|
|
1001
|
+
subject = attestation.get("subject")
|
|
1002
|
+
if not isinstance(subject, Mapping) or set(subject) != {
|
|
1003
|
+
"document", "readings", "context", "ranking"
|
|
1004
|
+
}:
|
|
1005
|
+
raise ValueError("invalid evaluation attestation subject")
|
|
1006
|
+
expected_subject = {
|
|
1007
|
+
"document": document_identity(document),
|
|
1008
|
+
"readings": bound_readings.receipt_ref() if bound_readings is not None else None,
|
|
1009
|
+
"context": bound_context.receipt_ref(),
|
|
1010
|
+
"ranking": {"schema": RANKING_SCHEMA_V2, "digest": receipt["receiptDigest"]},
|
|
1011
|
+
}
|
|
1012
|
+
if dict(subject) != expected_subject:
|
|
1013
|
+
raise ValueError("evaluation attestation subject mismatch")
|
|
1014
|
+
|
|
1015
|
+
validity = attestation.get("validity")
|
|
1016
|
+
if not isinstance(validity, Mapping) or set(validity) != {"issuedAt", "expiresAt"}:
|
|
1017
|
+
raise ValueError("invalid evaluation attestation validity")
|
|
1018
|
+
issued_at = _timestamp(validity.get("issuedAt"))
|
|
1019
|
+
expires_at = _timestamp(validity.get("expiresAt"))
|
|
1020
|
+
current = at.astimezone(timezone.utc) if at.tzinfo is not None else None
|
|
1021
|
+
if (
|
|
1022
|
+
current is None
|
|
1023
|
+
or issued_at >= expires_at
|
|
1024
|
+
or issued_at < _timestamp(bound_context.observed_at)
|
|
1025
|
+
or (expires_at - issued_at).total_seconds() > 900
|
|
1026
|
+
or current < issued_at
|
|
1027
|
+
or current >= expires_at
|
|
1028
|
+
):
|
|
1029
|
+
raise ValueError("evaluation attestation is not current")
|
|
1030
|
+
|
|
1031
|
+
anti_replay = attestation.get("antiReplay")
|
|
1032
|
+
if not isinstance(anti_replay, Mapping) or set(anti_replay) != {
|
|
1033
|
+
"nonce", "audience", "singleUse"
|
|
1034
|
+
}:
|
|
1035
|
+
raise ValueError("invalid evaluation attestation replay protection")
|
|
1036
|
+
nonce = anti_replay.get("nonce")
|
|
1037
|
+
audience = anti_replay.get("audience")
|
|
1038
|
+
if (
|
|
1039
|
+
not isinstance(nonce, str)
|
|
1040
|
+
or not re.fullmatch(r"[A-Za-z0-9_-]{24,128}", nonce)
|
|
1041
|
+
or nonce in set(consumed_nonces)
|
|
1042
|
+
or not isinstance(audience, str)
|
|
1043
|
+
or not re.fullmatch(r"[a-z][a-z0-9+.-]*://[A-Za-z0-9][A-Za-z0-9._:/-]{1,238}", audience)
|
|
1044
|
+
or audience != expected_audience
|
|
1045
|
+
or anti_replay.get("singleUse") is not True
|
|
1046
|
+
):
|
|
1047
|
+
raise ValueError("invalid evaluation attestation replay protection")
|
|
1048
|
+
|
|
1049
|
+
signature = attestation.get("signature")
|
|
1050
|
+
if not isinstance(signature, Mapping) or set(signature) != {
|
|
1051
|
+
"scheme", "keyId", "value"
|
|
1052
|
+
}:
|
|
1053
|
+
raise ValueError("invalid evaluation attestation signature")
|
|
1054
|
+
if signature.get("scheme") not in {"ed25519", "sigstore"}:
|
|
1055
|
+
raise ValueError("invalid evaluation attestation signature")
|
|
1056
|
+
if not isinstance(signature.get("keyId"), str) or not 3 <= len(signature["keyId"]) <= 200:
|
|
1057
|
+
raise ValueError("invalid evaluation attestation signature")
|
|
1058
|
+
if not isinstance(signature.get("value"), str) or not re.fullmatch(
|
|
1059
|
+
r"[A-Za-z0-9_+=./:-]{16,4096}", signature["value"]
|
|
1060
|
+
):
|
|
1061
|
+
raise ValueError("invalid evaluation attestation signature")
|
|
1062
|
+
signing_bytes = attestation_signing_bytes(attestation)
|
|
1063
|
+
if not signature_verifier(signing_bytes, signature):
|
|
1064
|
+
raise ValueError("evaluation attestation signature verification failed")
|
|
1065
|
+
return VerifiedEvaluation(
|
|
1066
|
+
attestation_id=attestation_id,
|
|
1067
|
+
issuer=issuer_id,
|
|
1068
|
+
nonce=nonce,
|
|
1069
|
+
audience=audience,
|
|
1070
|
+
payload_digest=canonical_digest(json.loads(signing_bytes)),
|
|
1071
|
+
ranking_digest=receipt["receiptDigest"],
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _condition_holds(
|
|
1076
|
+
condition: Mapping[str, Any],
|
|
1077
|
+
signals: Mapping[str, Mapping[str, Any]],
|
|
1078
|
+
readings: Mapping[str, Reading],
|
|
1079
|
+
) -> bool:
|
|
1080
|
+
"""True when a condition fires against the current readings.
|
|
1081
|
+
|
|
1082
|
+
A missing reading never fires unless the signal declares ``ABSENT zero``:
|
|
1083
|
+
absence of evidence must not raise a priority, or the ranking drifts towards
|
|
1084
|
+
whatever is least measured.
|
|
1085
|
+
"""
|
|
1086
|
+
name = condition.get("signal")
|
|
1087
|
+
signal = signals.get(name)
|
|
1088
|
+
if signal is None:
|
|
1089
|
+
return False
|
|
1090
|
+
reading = readings.get(name)
|
|
1091
|
+
kind = signal.get("kind")
|
|
1092
|
+
|
|
1093
|
+
if reading is not None and signal.get("window"):
|
|
1094
|
+
if reading.observed_age_seconds > parse_duration(str(signal["window"])):
|
|
1095
|
+
reading = None
|
|
1096
|
+
|
|
1097
|
+
if reading is None or (kind == "metric" and reading.value is None):
|
|
1098
|
+
if signal.get("absent", "hold") != "zero":
|
|
1099
|
+
return False
|
|
1100
|
+
reading = Reading(value=0.0)
|
|
1101
|
+
|
|
1102
|
+
if kind == "metric":
|
|
1103
|
+
op = NUMERIC_OPS.get(condition.get("op"))
|
|
1104
|
+
return bool(op and op(float(reading.value), float(condition.get("value", 0))))
|
|
1105
|
+
if kind == "event":
|
|
1106
|
+
if condition.get("op") == "changed":
|
|
1107
|
+
return reading.changed
|
|
1108
|
+
return reading.age_seconds >= float(condition.get("value", 0))
|
|
1109
|
+
return reading.age_seconds >= float(condition.get("value", 0))
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def evaluate(
|
|
1113
|
+
document: Mapping[str, Any],
|
|
1114
|
+
readings: Mapping[str, Reading] | None = None,
|
|
1115
|
+
*,
|
|
1116
|
+
ages: Mapping[str, float] | None = None,
|
|
1117
|
+
idle: Mapping[str, float] | None = None,
|
|
1118
|
+
) -> list[Evaluated]:
|
|
1119
|
+
"""Rank the document's priorities against a set of readings.
|
|
1120
|
+
|
|
1121
|
+
``ages`` is how long each priority has been open, in seconds, and drives
|
|
1122
|
+
escalation and decay. ``idle`` is how long the surface a priority touches has
|
|
1123
|
+
gone untouched, and drives the starvation term — the term that keeps
|
|
1124
|
+
development even instead of letting a fleet optimize into its hot spots.
|
|
1125
|
+
"""
|
|
1126
|
+
readings = dict(readings or {})
|
|
1127
|
+
ages = dict(ages or {})
|
|
1128
|
+
idle = dict(idle or {})
|
|
1129
|
+
signals = {signal["name"]: signal for signal in document.get("signals") or []}
|
|
1130
|
+
|
|
1131
|
+
results: list[Evaluated] = []
|
|
1132
|
+
for item in document.get("priorities") or []:
|
|
1133
|
+
tier = item.get("tier", "standard")
|
|
1134
|
+
intent = item.get("intent", "")
|
|
1135
|
+
base = float(item.get("base", 1))
|
|
1136
|
+
weight = base
|
|
1137
|
+
explanation: list[str] = []
|
|
1138
|
+
amended: list[str] = []
|
|
1139
|
+
retired = False
|
|
1140
|
+
|
|
1141
|
+
# Amendments run first: they can change the intent the weight applies to.
|
|
1142
|
+
for amend in item.get("amendments") or []:
|
|
1143
|
+
if not _condition_holds(amend, signals, readings):
|
|
1144
|
+
continue
|
|
1145
|
+
hold = amend.get("for")
|
|
1146
|
+
if hold:
|
|
1147
|
+
reading = readings.get(amend["signal"])
|
|
1148
|
+
if reading is None or reading.age_seconds < parse_duration(hold):
|
|
1149
|
+
continue
|
|
1150
|
+
if amend.get("rewriteIntent"):
|
|
1151
|
+
intent = amend["rewriteIntent"]
|
|
1152
|
+
if amend.get("setTier"):
|
|
1153
|
+
tier = amend["setTier"]
|
|
1154
|
+
if amend.get("setBase") is not None:
|
|
1155
|
+
base = float(amend["setBase"])
|
|
1156
|
+
weight = base
|
|
1157
|
+
if amend.get("retire"):
|
|
1158
|
+
retired = True
|
|
1159
|
+
amended.append(amend.get("because") or f"amended on {amend['signal']}")
|
|
1160
|
+
|
|
1161
|
+
for rule in item.get("rules") or []:
|
|
1162
|
+
if not _condition_holds(rule, signals, readings):
|
|
1163
|
+
continue
|
|
1164
|
+
factor = float(rule.get("factor", 1))
|
|
1165
|
+
weight *= factor
|
|
1166
|
+
explanation.append(
|
|
1167
|
+
f"{rule['action'].lower()} ×{_fmt(factor)} on "
|
|
1168
|
+
f"{rule['signal']} {rule['op']} {_fmt(float(rule['value']))}"
|
|
1169
|
+
+ (f" — {rule['because']}" if rule.get("because") else "")
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
age = float(ages.get(item["id"], 0.0))
|
|
1173
|
+
escalate = item.get("escalate")
|
|
1174
|
+
if escalate and age > 0:
|
|
1175
|
+
periods = age / parse_duration(escalate["per"])
|
|
1176
|
+
multiplier = float(escalate["factor"]) ** periods
|
|
1177
|
+
weight *= multiplier
|
|
1178
|
+
explanation.append(f"escalated ×{multiplier:.2f} over {periods:.1f} period(s) unresolved")
|
|
1179
|
+
decay = item.get("decay")
|
|
1180
|
+
if decay and age > 0:
|
|
1181
|
+
periods = age / parse_duration(decay["per"])
|
|
1182
|
+
multiplier = float(decay["factor"]) ** periods
|
|
1183
|
+
weight *= multiplier
|
|
1184
|
+
explanation.append(f"decayed ×{multiplier:.2f} over {periods:.1f} period(s) untaken")
|
|
1185
|
+
|
|
1186
|
+
starvation = item.get("starvation")
|
|
1187
|
+
if starvation:
|
|
1188
|
+
untouched = float(idle.get(item["id"], 0.0))
|
|
1189
|
+
periods = untouched / parse_duration(starvation["per"])
|
|
1190
|
+
points = float(starvation["points"]) * periods
|
|
1191
|
+
cap = starvation.get("cap")
|
|
1192
|
+
if cap is not None:
|
|
1193
|
+
points = min(points, float(cap))
|
|
1194
|
+
if points > 0:
|
|
1195
|
+
weight += points
|
|
1196
|
+
explanation.append(f"starvation +{points:.1f} after {periods:.1f} idle period(s)")
|
|
1197
|
+
|
|
1198
|
+
satisfied = False
|
|
1199
|
+
condition = item.get("satisfiedWhen")
|
|
1200
|
+
if condition and _condition_holds(condition, signals, readings):
|
|
1201
|
+
satisfied = True
|
|
1202
|
+
|
|
1203
|
+
results.append(
|
|
1204
|
+
Evaluated(
|
|
1205
|
+
id=item["id"],
|
|
1206
|
+
tier=tier,
|
|
1207
|
+
intent=intent,
|
|
1208
|
+
base=base,
|
|
1209
|
+
effective=weight,
|
|
1210
|
+
satisfied=satisfied,
|
|
1211
|
+
explanation=explanation,
|
|
1212
|
+
amended=amended,
|
|
1213
|
+
retired=retired,
|
|
1214
|
+
)
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
results.sort(key=lambda evaluated: evaluated.rank_key)
|
|
1218
|
+
return results
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
# --------------------------------------------------------------------------
|
|
1222
|
+
# complementarity
|
|
1223
|
+
# --------------------------------------------------------------------------
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _jaccard(a: Iterable[str], b: Iterable[str]) -> float:
|
|
1227
|
+
left, right = set(a), set(b)
|
|
1228
|
+
if not left or not right:
|
|
1229
|
+
return 0.0
|
|
1230
|
+
return len(left & right) / len(left | right)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def complementarity(
|
|
1234
|
+
document: Mapping[str, Any],
|
|
1235
|
+
*,
|
|
1236
|
+
comovement: Mapping[tuple[str, str], float] | None = None,
|
|
1237
|
+
weights: tuple[float, float, float] = (0.4, 0.2, 0.4),
|
|
1238
|
+
) -> dict[tuple[str, str], dict[str, float]]:
|
|
1239
|
+
"""Pairwise complementarity in [-1, 1].
|
|
1240
|
+
|
|
1241
|
+
Three observables, deliberately kept separate so a score can be argued with:
|
|
1242
|
+
|
|
1243
|
+
* ``paths`` — Jaccard overlap of the surfaces two priorities touch. Shared
|
|
1244
|
+
surface is where both the savings and the collisions live.
|
|
1245
|
+
* ``signals`` — overlap of the measurements that drive them.
|
|
1246
|
+
* ``observed``— did satisfying one actually move the other's signal, and in
|
|
1247
|
+
which direction. This is the only outcome-grounded term, and the one that
|
|
1248
|
+
catches a pairing that looks complementary and is not.
|
|
1249
|
+
|
|
1250
|
+
A declared ``RELATION`` overrides the computation: a human who has looked at
|
|
1251
|
+
two priorities outranks a similarity score.
|
|
1252
|
+
"""
|
|
1253
|
+
comovement = dict(comovement or {})
|
|
1254
|
+
declared: dict[tuple[str, str], Mapping[str, Any]] = {}
|
|
1255
|
+
for relation in document.get("relations") or []:
|
|
1256
|
+
declared[tuple(sorted((relation["a"], relation["b"])))] = relation
|
|
1257
|
+
|
|
1258
|
+
items = list(document.get("priorities") or [])
|
|
1259
|
+
signals_of = {
|
|
1260
|
+
item["id"]: {rule["signal"] for rule in item.get("rules") or []}
|
|
1261
|
+
| ({item["satisfiedWhen"]["signal"]} if item.get("satisfiedWhen") else set())
|
|
1262
|
+
for item in items
|
|
1263
|
+
}
|
|
1264
|
+
touches_of = {item["id"]: set(item.get("touches") or []) for item in items}
|
|
1265
|
+
|
|
1266
|
+
matrix: dict[tuple[str, str], dict[str, float]] = {}
|
|
1267
|
+
for left_index, left in enumerate(items):
|
|
1268
|
+
for right in items[left_index + 1 :]:
|
|
1269
|
+
key = tuple(sorted((left["id"], right["id"])))
|
|
1270
|
+
paths = _jaccard(touches_of[left["id"]], touches_of[right["id"]])
|
|
1271
|
+
shared = _jaccard(signals_of[left["id"]], signals_of[right["id"]])
|
|
1272
|
+
observed = float(comovement.get(key, comovement.get(key[::-1], 0.0)))
|
|
1273
|
+
score = weights[0] * paths + weights[1] * shared + weights[2] * observed
|
|
1274
|
+
entry = {
|
|
1275
|
+
"paths": round(paths, 4),
|
|
1276
|
+
"signals": round(shared, 4),
|
|
1277
|
+
"observed": round(observed, 4),
|
|
1278
|
+
"score": round(max(-1.0, min(1.0, score)), 4),
|
|
1279
|
+
"source": "measured",
|
|
1280
|
+
}
|
|
1281
|
+
relation = declared.get(key)
|
|
1282
|
+
if relation is not None:
|
|
1283
|
+
strength = relation.get("strength")
|
|
1284
|
+
if strength is None:
|
|
1285
|
+
strength = {"complementary": 1.0, "antagonistic": -1.0, "neutral": 0.0}[relation["kind"]]
|
|
1286
|
+
entry["score"] = round(float(strength), 4)
|
|
1287
|
+
entry["source"] = "declared"
|
|
1288
|
+
entry["kind"] = relation["kind"]
|
|
1289
|
+
matrix[key] = entry
|
|
1290
|
+
return matrix
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def antagonisms(matrix: Mapping[tuple[str, str], Mapping[str, float]], threshold: float = -0.2) -> list[dict[str, Any]]:
|
|
1294
|
+
"""Pairs that work against each other.
|
|
1295
|
+
|
|
1296
|
+
Reported rather than silently down-weighted: a standing negative score means
|
|
1297
|
+
two intents disagree, which is a question for a human and not an
|
|
1298
|
+
optimization to solve.
|
|
1299
|
+
"""
|
|
1300
|
+
return [
|
|
1301
|
+
{"pair": list(pair), **dict(entry)}
|
|
1302
|
+
for pair, entry in sorted(matrix.items())
|
|
1303
|
+
if float(entry.get("score", 0)) <= threshold
|
|
1304
|
+
]
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def select(
|
|
1308
|
+
evaluated: Sequence[Evaluated],
|
|
1309
|
+
matrix: Mapping[tuple[str, str], Mapping[str, float]],
|
|
1310
|
+
*,
|
|
1311
|
+
capacity: int = 3,
|
|
1312
|
+
lam: float = 0.5,
|
|
1313
|
+
) -> list[Evaluated]:
|
|
1314
|
+
"""Greedy set selection: weight plus complementarity, floor items mandatory.
|
|
1315
|
+
|
|
1316
|
+
Ranking one item at a time produces batches that fight each other, so the
|
|
1317
|
+
unit of selection is the set.
|
|
1318
|
+
"""
|
|
1319
|
+
open_items = [item for item in evaluated if not item.satisfied and not item.retired]
|
|
1320
|
+
chosen = [item for item in open_items if item.tier == "floor"]
|
|
1321
|
+
remaining = [item for item in open_items if item.tier != "floor"]
|
|
1322
|
+
|
|
1323
|
+
def pair_bonus(candidate: Evaluated, picked: Sequence[Evaluated]) -> float:
|
|
1324
|
+
total = 0.0
|
|
1325
|
+
for other in picked:
|
|
1326
|
+
key = tuple(sorted((candidate.id, other.id)))
|
|
1327
|
+
total += float(matrix.get(key, {}).get("score", 0.0))
|
|
1328
|
+
return total
|
|
1329
|
+
|
|
1330
|
+
while remaining and len(chosen) < max(capacity, len(chosen)):
|
|
1331
|
+
best = max(remaining, key=lambda item: item.effective + lam * pair_bonus(item, chosen))
|
|
1332
|
+
chosen.append(best)
|
|
1333
|
+
remaining.remove(best)
|
|
1334
|
+
return sorted(chosen, key=lambda item: item.rank_key)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
# --------------------------------------------------------------------------
|
|
1338
|
+
# projection to agent-facing files
|
|
1339
|
+
# --------------------------------------------------------------------------
|
|
1340
|
+
|
|
1341
|
+
#: Where each agent reads its standing instructions. Vendors will not converge on
|
|
1342
|
+
#: one format, so the document is the authority and each of these is generated.
|
|
1343
|
+
PROJECTION_TARGETS = {
|
|
1344
|
+
"agents": "AGENTS.md", # Codex / ChatGPT, and the ecosystem's own contract
|
|
1345
|
+
"claude": "CLAUDE.md", # Claude Code
|
|
1346
|
+
"gemini": "GEMINI.md", # Gemini CLI
|
|
1347
|
+
"cursor": ".cursor/rules/priority.mdc",
|
|
1348
|
+
"json": ".priority/ranking.json", # for CI, hooks, and non-agent consumers
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
MARKER_BEGIN = "<!-- BEGIN wellmanifest.priority -->"
|
|
1352
|
+
MARKER_END = "<!-- END wellmanifest.priority -->"
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def project_markdown(evaluated: Sequence[Evaluated], *, title: str = "Priorities") -> str:
|
|
1356
|
+
"""The shared body every markdown projection embeds.
|
|
1357
|
+
|
|
1358
|
+
Ordering is explicit and the reason for each rank travels with it: an agent
|
|
1359
|
+
that can see why something ranks where it does can tell when the reason has
|
|
1360
|
+
stopped applying, which is the whole point of publishing the explanation.
|
|
1361
|
+
"""
|
|
1362
|
+
lines = [
|
|
1363
|
+
MARKER_BEGIN,
|
|
1364
|
+
f"## {title}",
|
|
1365
|
+
"",
|
|
1366
|
+
"Generated from the priority document. Do not edit this block by hand:",
|
|
1367
|
+
"it is regenerated, and a hand edit is reported as drift.",
|
|
1368
|
+
"",
|
|
1369
|
+
"Work the tiers in order. A `floor` item outranks every `standard` item",
|
|
1370
|
+
"regardless of weight — that is the point of the tier, not a tie-break.",
|
|
1371
|
+
"",
|
|
1372
|
+
]
|
|
1373
|
+
for tier in TIERS:
|
|
1374
|
+
band = [item for item in evaluated if item.tier == tier and not item.retired]
|
|
1375
|
+
if not band:
|
|
1376
|
+
continue
|
|
1377
|
+
lines.append(f"### {tier}")
|
|
1378
|
+
lines.append("")
|
|
1379
|
+
for item in band:
|
|
1380
|
+
state = " *(satisfied)*" if item.satisfied else ""
|
|
1381
|
+
lines.append(f"- **{item.id}** — {item.intent}{state}")
|
|
1382
|
+
lines.append(f" - weight {item.effective:.1f} (base {item.base:.0f})")
|
|
1383
|
+
for reason in item.explanation:
|
|
1384
|
+
lines.append(f" - {reason}")
|
|
1385
|
+
for reason in item.amended:
|
|
1386
|
+
lines.append(f" - amended: {reason}")
|
|
1387
|
+
lines.append("")
|
|
1388
|
+
lines.append(MARKER_END)
|
|
1389
|
+
return "\n".join(lines)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def splice(existing: str, block: str) -> str:
|
|
1393
|
+
"""Replace the managed block in *existing*, or append it.
|
|
1394
|
+
|
|
1395
|
+
Everything outside the markers is left alone: these files carry
|
|
1396
|
+
hand-written instructions too, and a projection that overwrote them would
|
|
1397
|
+
make itself unwelcome.
|
|
1398
|
+
"""
|
|
1399
|
+
if MARKER_BEGIN in existing and MARKER_END in existing:
|
|
1400
|
+
head = existing.split(MARKER_BEGIN, 1)[0]
|
|
1401
|
+
tail = existing.split(MARKER_END, 1)[1]
|
|
1402
|
+
return head + block + tail
|
|
1403
|
+
separator = "" if not existing or existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n")
|
|
1404
|
+
return existing + separator + block + "\n"
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def project(
|
|
1408
|
+
document: Mapping[str, Any],
|
|
1409
|
+
evaluated: Sequence[Evaluated],
|
|
1410
|
+
targets: Mapping[str, str] | None = None,
|
|
1411
|
+
readings: ReadingsEnvelope | None = None,
|
|
1412
|
+
context: EvaluationContext | None = None,
|
|
1413
|
+
) -> dict[str, str]:
|
|
1414
|
+
"""Render every agent-facing projection. Returns {path: content}."""
|
|
1415
|
+
targets = dict(targets or PROJECTION_TARGETS)
|
|
1416
|
+
block = project_markdown(evaluated)
|
|
1417
|
+
out: dict[str, str] = {}
|
|
1418
|
+
for name, path in targets.items():
|
|
1419
|
+
if name == "json":
|
|
1420
|
+
receipt = (
|
|
1421
|
+
ranking_receipt_v2(document, context, readings)
|
|
1422
|
+
if context is not None
|
|
1423
|
+
else ranking_receipt(document, evaluated, readings)
|
|
1424
|
+
)
|
|
1425
|
+
out[path] = json.dumps(
|
|
1426
|
+
receipt, indent=2
|
|
1427
|
+
) + "\n"
|
|
1428
|
+
else:
|
|
1429
|
+
out[path] = block + "\n"
|
|
1430
|
+
return out
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def drift(root: Path, rendered: Mapping[str, str]) -> list[Finding]:
|
|
1434
|
+
"""Report projections that no longer match the document."""
|
|
1435
|
+
findings: list[Finding] = []
|
|
1436
|
+
for path, content in rendered.items():
|
|
1437
|
+
target = root / path
|
|
1438
|
+
if not target.exists():
|
|
1439
|
+
findings.append(Finding("PRIORITY-PROJECTION-001", f"projection missing: {path}", path))
|
|
1440
|
+
continue
|
|
1441
|
+
current = target.read_text(encoding="utf-8")
|
|
1442
|
+
if path.endswith(".json"):
|
|
1443
|
+
if current.strip() != content.strip():
|
|
1444
|
+
findings.append(Finding("PRIORITY-PROJECTION-001", f"projection is stale: {path}", path))
|
|
1445
|
+
continue
|
|
1446
|
+
if MARKER_BEGIN not in current:
|
|
1447
|
+
findings.append(Finding("PRIORITY-PROJECTION-001", f"managed block missing from {path}", path))
|
|
1448
|
+
elif splice(current, project_markdown_block(content)) != current:
|
|
1449
|
+
findings.append(Finding("PRIORITY-PROJECTION-001", f"projection is stale: {path}", path))
|
|
1450
|
+
return findings
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def project_markdown_block(content: str) -> str:
|
|
1454
|
+
"""Extract the managed block from a rendered projection."""
|
|
1455
|
+
if MARKER_BEGIN in content and MARKER_END in content:
|
|
1456
|
+
return MARKER_BEGIN + content.split(MARKER_BEGIN, 1)[1].split(MARKER_END, 1)[0] + MARKER_END
|
|
1457
|
+
return content.strip()
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
# --------------------------------------------------------------------------
|
|
1461
|
+
# CLI
|
|
1462
|
+
# --------------------------------------------------------------------------
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _load(path: Path) -> dict[str, Any]:
|
|
1466
|
+
text = path.read_text(encoding="utf-8")
|
|
1467
|
+
if path.suffix == ".json":
|
|
1468
|
+
return json.loads(text)
|
|
1469
|
+
return parse(text)
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _report(findings: Sequence[Finding], fmt: str) -> str:
|
|
1473
|
+
if fmt == "json":
|
|
1474
|
+
return json.dumps([finding.as_dict() for finding in findings], indent=2)
|
|
1475
|
+
return "\n".join(str(finding) for finding in findings) or "ok"
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1479
|
+
parser = argparse.ArgumentParser(prog="priority", description=__doc__.splitlines()[0])
|
|
1480
|
+
parser.add_argument("--format", default="text", choices=["text", "json"])
|
|
1481
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1482
|
+
|
|
1483
|
+
for name in ("validate", "render", "rank", "matrix", "select", "receipt", "project", "check"):
|
|
1484
|
+
child = sub.add_parser(name)
|
|
1485
|
+
child.add_argument("document", type=Path)
|
|
1486
|
+
child.add_argument("--root", type=Path, default=Path("."))
|
|
1487
|
+
child.add_argument(
|
|
1488
|
+
"--format", choices=("text", "json"), default=argparse.SUPPRESS
|
|
1489
|
+
)
|
|
1490
|
+
if name in {"rank", "select", "receipt", "project", "check"}:
|
|
1491
|
+
child.add_argument(
|
|
1492
|
+
"--readings",
|
|
1493
|
+
type=Path,
|
|
1494
|
+
help="wellmanifest.priority/readings/v1 JSON envelope",
|
|
1495
|
+
)
|
|
1496
|
+
child.add_argument(
|
|
1497
|
+
"--context",
|
|
1498
|
+
type=Path,
|
|
1499
|
+
help="wellmanifest.priority/evaluation-context/v1 JSON envelope",
|
|
1500
|
+
)
|
|
1501
|
+
if name == "select":
|
|
1502
|
+
child.add_argument("--capacity", type=int, default=3)
|
|
1503
|
+
|
|
1504
|
+
args = parser.parse_args(argv)
|
|
1505
|
+
document = _load(args.document)
|
|
1506
|
+
|
|
1507
|
+
findings = validate(document)
|
|
1508
|
+
if args.command == "validate":
|
|
1509
|
+
print(_report(findings, args.format))
|
|
1510
|
+
return 1 if findings else 0
|
|
1511
|
+
if findings:
|
|
1512
|
+
print(_report(findings, args.format), file=sys.stderr)
|
|
1513
|
+
return 1
|
|
1514
|
+
|
|
1515
|
+
if args.command == "render":
|
|
1516
|
+
print(render(document))
|
|
1517
|
+
return 0
|
|
1518
|
+
|
|
1519
|
+
readings: dict[str, Reading] = {}
|
|
1520
|
+
envelope: ReadingsEnvelope | None = None
|
|
1521
|
+
if getattr(args, "readings", None):
|
|
1522
|
+
raw = json.loads(args.readings.read_text())
|
|
1523
|
+
envelope = load_readings(document, raw)
|
|
1524
|
+
readings = dict(envelope.readings)
|
|
1525
|
+
|
|
1526
|
+
context: EvaluationContext | None = None
|
|
1527
|
+
if getattr(args, "context", None):
|
|
1528
|
+
raw_context = json.loads(args.context.read_text())
|
|
1529
|
+
context = load_evaluation_context(document, raw_context, envelope)
|
|
1530
|
+
|
|
1531
|
+
evaluated = evaluate(
|
|
1532
|
+
document,
|
|
1533
|
+
readings,
|
|
1534
|
+
ages=context.ages if context is not None else None,
|
|
1535
|
+
idle=context.idle if context is not None else None,
|
|
1536
|
+
)
|
|
1537
|
+
matrix = complementarity(document)
|
|
1538
|
+
|
|
1539
|
+
if args.command == "rank":
|
|
1540
|
+
if args.format == "json":
|
|
1541
|
+
print(json.dumps([item.as_dict() for item in evaluated], indent=2))
|
|
1542
|
+
else:
|
|
1543
|
+
for item in evaluated:
|
|
1544
|
+
mark = "x" if item.satisfied else " "
|
|
1545
|
+
print(f"[{mark}] {item.tier:14s} {item.effective:8.1f} {item.id} — {item.intent}")
|
|
1546
|
+
for reason in item.explanation + item.amended:
|
|
1547
|
+
print(f" · {reason}")
|
|
1548
|
+
return 0
|
|
1549
|
+
|
|
1550
|
+
if args.command == "matrix":
|
|
1551
|
+
payload = {
|
|
1552
|
+
"pairs": {f"{a}|{b}": entry for (a, b), entry in sorted(matrix.items())},
|
|
1553
|
+
"antagonisms": antagonisms(matrix),
|
|
1554
|
+
}
|
|
1555
|
+
if args.format == "json":
|
|
1556
|
+
print(json.dumps(payload, indent=2))
|
|
1557
|
+
else:
|
|
1558
|
+
for (a, b), entry in sorted(matrix.items()):
|
|
1559
|
+
print(f"{entry['score']:+.2f} [{entry['source']:8s}] {a} ~ {b}")
|
|
1560
|
+
for item in payload["antagonisms"]:
|
|
1561
|
+
print(f"ANTAGONISM {item['pair'][0]} ~ {item['pair'][1]} ({item['score']:+.2f})")
|
|
1562
|
+
return 0
|
|
1563
|
+
|
|
1564
|
+
if args.command == "select":
|
|
1565
|
+
chosen = select(evaluated, matrix, capacity=args.capacity)
|
|
1566
|
+
if args.format == "json":
|
|
1567
|
+
print(json.dumps([item.as_dict() for item in chosen], indent=2))
|
|
1568
|
+
else:
|
|
1569
|
+
for item in chosen:
|
|
1570
|
+
print(f"{item.tier:14s} {item.effective:8.1f} {item.id} — {item.intent}")
|
|
1571
|
+
return 0
|
|
1572
|
+
|
|
1573
|
+
if args.command == "receipt":
|
|
1574
|
+
receipt = (
|
|
1575
|
+
ranking_receipt_v2(document, context, envelope)
|
|
1576
|
+
if context is not None
|
|
1577
|
+
else ranking_receipt(document, evaluated, envelope)
|
|
1578
|
+
)
|
|
1579
|
+
print(json.dumps(receipt, indent=2))
|
|
1580
|
+
return 0
|
|
1581
|
+
|
|
1582
|
+
rendered = project(document, evaluated, readings=envelope, context=context)
|
|
1583
|
+
if args.command == "project":
|
|
1584
|
+
if args.format == "json":
|
|
1585
|
+
print(json.dumps(rendered, indent=2))
|
|
1586
|
+
else:
|
|
1587
|
+
for path, content in rendered.items():
|
|
1588
|
+
print(f"--- {path}")
|
|
1589
|
+
print(content)
|
|
1590
|
+
return 0
|
|
1591
|
+
|
|
1592
|
+
problems = drift(args.root, rendered)
|
|
1593
|
+
print(_report(problems, args.format))
|
|
1594
|
+
return 1 if problems else 0
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
if __name__ == "__main__":
|
|
1598
|
+
raise SystemExit(main())
|