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 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())