marcus-toolkit 0.1.0__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.
@@ -0,0 +1,489 @@
1
+ """
2
+ The candidate lifecycle: agents ratify, the controller promotes.
3
+
4
+ Decided by Larry on 2026-11-06 for the Minecraft experiment
5
+ (``minecraft-spike/DESIGN-ratified-growth.md``) and lifted here unchanged in
6
+ its rules. A coordinator does not grow the task graph by itself. What it would
7
+ have added as a task is posted as a *candidate*: a marker under the reserved
8
+ subject prefix ``candidate.`` whose text carries the task spec, visible to
9
+ every agent like any other opinion. An agent supports a candidate by posting
10
+ the same subject on its own report or opinion, with its evidence; it objects
11
+ with a text beginning ``OBJECT:``; it abstains with ``NO EVIDENCE:``. A
12
+ candidate is promoted to a task when two distinct agents support it and
13
+ supporters outnumber objectors; it evaporates once three distinct agents have
14
+ been shown it and declined it. At most crew-size candidates are open at once.
15
+ The controller's own posting never counts, and only the controller closes one.
16
+
17
+ Everything here is a pure rule over the entries that
18
+ ``query_project_history(..., "opinions")`` returns, so it is tested on its
19
+ own; :mod:`marcus_toolkit.tending` applies it against a live board.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ from collections.abc import Callable, Iterable, Sequence
27
+ from dataclasses import dataclass, field
28
+ from datetime import UTC, datetime
29
+ from typing import Any
30
+
31
+ CANDIDATE_PREFIX = "candidate."
32
+ PROPOSAL_PREFIX = "propose."
33
+ #: "Only two agents need to support a promotion." (Larry, 2026-11-06)
34
+ THRESHOLD = 2
35
+ #: The superseded seconds window (Larry, 2026-11-06, "evaporation after 4.5
36
+ #: minutes"), kept so the runs measured with it stay reproducible.
37
+ EVAPORATION_SECONDS = 270
38
+ #: "views, k=3" (Larry, 2026-11-06): a candidate evaporates once this many
39
+ #: distinct agents have been shown it and then had a turn in which they could
40
+ #: have supported it and did not. A candidate no agent has declined never
41
+ #: evaporates, so "open" is a terminal state in the record.
42
+ DECLINES_NEEDED = 3
43
+ #: A candidate born from a corroborated observation carries its observers as
44
+ #: supporters and promotes once this many seconds pass with no objection.
45
+ VETO_SECONDS = 120
46
+ POST_MARK = "CANDIDATE: "
47
+ OBJECT_MARK = "OBJECT:"
48
+ #: How an agent says it cannot judge a candidate: neither support nor an
49
+ #: objection, and a decline the moment it is written.
50
+ ABSTAIN_MARK = "NO EVIDENCE:"
51
+ #: What the controller posts on a candidate's own subject once it is closed.
52
+ CLOSE_MARK = "CLOSED:"
53
+ #: What became of a promoted candidate's task, posted by the controller on the
54
+ #: candidate's subject whenever it changes: ``TASK: open``, ``TASK: held by
55
+ #: <agent>``, ``TASK: blocked``, ``TASK: done``.
56
+ TASK_MARK = "TASK:"
57
+ TASK_STATES = ("open", "held", "blocked", "done")
58
+ #: The label every promoted or proposed task carries, so a record can tell
59
+ #: seeded work from work the crew ratified.
60
+ PLANNED_LABEL = "planned"
61
+
62
+
63
+ def task_state(task: dict[str, Any]) -> tuple[str, str | None]:
64
+ """
65
+ Read one of ``TASK_STATES`` and the holder off a board task row.
66
+
67
+ Parameters
68
+ ----------
69
+ task : Dict[str, Any]
70
+ A task row with ``status`` and ``assigned_to``.
71
+
72
+ Returns
73
+ -------
74
+ Tuple[str, Optional[str]]
75
+ ``("done", None)``, ``("blocked", None)``, ``("held", agent)`` for a
76
+ task in progress or assigned, else ``("open", None)``.
77
+ """
78
+ status = str(task.get("status") or "").strip().lower()
79
+ holder = str(task.get("assigned_to") or "").strip() or None
80
+ if status in ("done", "completed"):
81
+ return "done", None
82
+ if status == "blocked":
83
+ return "blocked", None
84
+ if status == "in_progress" or holder:
85
+ return "held", holder
86
+ return "open", None
87
+
88
+
89
+ def task_line(state: str, held_by: str | None = None) -> str:
90
+ """Render the controller's status line: ``TASK: held by Ash``, ``TASK: blocked``."""
91
+ if state == "held" and held_by:
92
+ return f"{TASK_MARK} held by {held_by}"
93
+ return f"{TASK_MARK} {state}"
94
+
95
+
96
+ def parse_task_line(text: str) -> tuple[str, str | None] | None:
97
+ """
98
+ Read the state and holder a ``TASK:`` line carries, or ``None`` for other text.
99
+
100
+ Parameters
101
+ ----------
102
+ text : str
103
+ An opinion's text.
104
+
105
+ Returns
106
+ -------
107
+ Optional[Tuple[str, Optional[str]]]
108
+ ``("held", "Ash")``, ``("blocked", None)``; a line with an unknown
109
+ state gives ``None``, so a typo never reads as a status.
110
+ """
111
+ body = str(text or "").strip()
112
+ if not body.upper().startswith(TASK_MARK):
113
+ return None
114
+ rest = body[len(TASK_MARK) :].strip()
115
+ if rest.lower().startswith("held by "):
116
+ holder = rest[len("held by ") :].strip()
117
+ return ("held", holder or None)
118
+ state = rest.lower()
119
+ return (state, None) if state in TASK_STATES else None
120
+
121
+
122
+ @dataclass
123
+ class Candidate:
124
+ """One open candidate as read off the board."""
125
+
126
+ subject: str
127
+ spec: dict[str, Any]
128
+ posted_at: str
129
+ supporters: list[str] = field(default_factory=list)
130
+ objectors: list[str] = field(default_factory=list)
131
+ #: Agents whose agreement on an observation produced the candidate; they
132
+ #: count as supporters, and support born this way waits the veto window.
133
+ observers: list[str] = field(default_factory=list)
134
+ #: Agents that answered ``NO EVIDENCE:``.
135
+ abstainers: list[str] = field(default_factory=list)
136
+ #: When each agent's current answer landed, by agent.
137
+ answered_at: dict[str, str] = field(default_factory=dict)
138
+
139
+ def support_complete_at(self) -> str | None:
140
+ """
141
+ Return when the last supporter needed to reach the threshold posted.
142
+
143
+ The settling window runs from here, not from the posting.
144
+
145
+ Returns
146
+ -------
147
+ Optional[str]
148
+ The timestamp, or ``None`` when support is short of the threshold
149
+ or the record carries no times.
150
+ """
151
+ observers = set(self.observers)
152
+ times = sorted(
153
+ self.answered_at[agent]
154
+ for agent in self.supporters
155
+ if agent not in observers and self.answered_at.get(agent)
156
+ )
157
+ return times[THRESHOLD - 1] if len(times) >= THRESHOLD else None
158
+
159
+ def age_seconds(self, now: str) -> float:
160
+ """Return the seconds between the controller's posting and ``now``."""
161
+ return (_when(now) - _when(self.posted_at)).total_seconds()
162
+
163
+
164
+ def _when(value: str) -> datetime:
165
+ text = str(value).replace("Z", "+00:00")
166
+ when = datetime.fromisoformat(text)
167
+ return when if when.tzinfo else when.replace(tzinfo=UTC)
168
+
169
+
170
+ def _slug(name: str) -> str:
171
+ return ".".join(t for t in re.split(r"[^a-z0-9]+", str(name).lower()) if t)
172
+
173
+
174
+ def candidate_subject(name: str) -> str:
175
+ """Return the reserved subject a candidate with this name is posted under."""
176
+ return CANDIDATE_PREFIX + _slug(name)
177
+
178
+
179
+ def post_text(spec: dict[str, Any]) -> str:
180
+ """Render the controller's posting: the spec as JSON after the mark."""
181
+ return POST_MARK + json.dumps(spec, ensure_ascii=False)
182
+
183
+
184
+ def parse_candidate(
185
+ entry: dict[str, Any], controller_ids: set[str]
186
+ ) -> Candidate | None:
187
+ """
188
+ Read one opinions entry as a candidate, or ``None`` if it is not one.
189
+
190
+ Parameters
191
+ ----------
192
+ entry : Dict[str, Any]
193
+ One entry from ``query_project_history(..., "opinions")``: ``subject``
194
+ and ``opinions`` (each with ``agent_id``, ``what``, ``timestamp``).
195
+ controller_ids : Set[str]
196
+ The controller processes' agent ids. Their postings define the
197
+ candidate and never count as support.
198
+
199
+ Returns
200
+ -------
201
+ Optional[Candidate]
202
+ The candidate with its supporters and objectors in order of first
203
+ appearance, each agent counted once; an agent's latest word wins.
204
+ A closed candidate parses as ``None``.
205
+ """
206
+ subject = str(entry.get("subject") or "")
207
+ if not subject.startswith(CANDIDATE_PREFIX):
208
+ return None
209
+ opinions = sorted(
210
+ entry.get("opinions") or [], key=lambda o: str(o.get("timestamp") or "")
211
+ )
212
+ spec: dict[str, Any] | None = None
213
+ posted_at: str | None = None
214
+ for o in opinions:
215
+ if o.get("agent_id") in controller_ids and str(o.get("what") or "").startswith(
216
+ POST_MARK
217
+ ):
218
+ try:
219
+ spec = json.loads(str(o["what"])[len(POST_MARK) :])
220
+ except json.JSONDecodeError:
221
+ continue
222
+ posted_at = str(o.get("timestamp"))
223
+ break
224
+ if spec is None or posted_at is None:
225
+ return None
226
+ if any(
227
+ o.get("agent_id") in controller_ids
228
+ and str(o.get("what") or "").strip().upper().startswith(CLOSE_MARK)
229
+ for o in opinions
230
+ ):
231
+ return None
232
+ latest: dict[str, str] = {}
233
+ latest_at: dict[str, str] = {}
234
+ order: list[str] = []
235
+ for o in opinions:
236
+ agent = str(o.get("agent_id") or "")
237
+ if not agent or agent in controller_ids:
238
+ continue
239
+ if agent not in latest:
240
+ order.append(agent)
241
+ latest[agent] = str(o.get("what") or "").strip()
242
+ stamp = str(o.get("timestamp") or "").strip()
243
+ if stamp:
244
+ latest_at[agent] = stamp
245
+
246
+ def mark(agent: str) -> str:
247
+ said = latest[agent].strip().upper()
248
+ if said.startswith(OBJECT_MARK):
249
+ return "object"
250
+ if said.startswith(ABSTAIN_MARK):
251
+ return "abstain"
252
+ return "support"
253
+
254
+ marks = {a: mark(a) for a in order}
255
+ supporters = [a for a in order if marks[a] == "support"]
256
+ objectors = [a for a in order if marks[a] == "object"]
257
+ abstainers = [a for a in order if marks[a] == "abstain"]
258
+ observers = [
259
+ str(a)
260
+ for a in (spec.get("observers") or [])
261
+ if str(a) and str(a) not in controller_ids
262
+ ]
263
+ for agent in observers:
264
+ answered = agent in supporters or agent in objectors or agent in abstainers
265
+ if not answered:
266
+ supporters.append(agent)
267
+ proposer = str(spec.get("proposer") or "")
268
+ if proposer and proposer not in controller_ids:
269
+ if (
270
+ proposer not in supporters
271
+ and proposer not in objectors
272
+ and proposer not in abstainers
273
+ ):
274
+ supporters.insert(0, proposer)
275
+ proposed_at = str(spec.get("proposed_at") or "").strip()
276
+ if proposed_at and proposer not in latest_at:
277
+ latest_at[proposer] = proposed_at
278
+ return Candidate(
279
+ subject=subject,
280
+ spec=spec,
281
+ posted_at=posted_at,
282
+ supporters=supporters,
283
+ objectors=objectors,
284
+ observers=observers,
285
+ abstainers=abstainers,
286
+ answered_at=latest_at,
287
+ )
288
+
289
+
290
+ def decide(
291
+ candidate: Candidate,
292
+ now: str,
293
+ veto_seconds: int = VETO_SECONDS,
294
+ evaporation_seconds: int = EVAPORATION_SECONDS,
295
+ attention_seconds: float | None = None,
296
+ declined_by: int | None = None,
297
+ declines_needed: int = DECLINES_NEEDED,
298
+ awaiting: int | None = None,
299
+ settling_seconds: int = 0,
300
+ ) -> str:
301
+ """
302
+ Apply the fixed rule: ``"promote"``, ``"wait"`` or ``"evaporate"``.
303
+
304
+ A candidate promotes when at least two distinct agents support it and
305
+ supporters outnumber objectors (a tie does not carry; declines are not
306
+ votes against). Support born from observers promotes only after
307
+ ``veto_seconds``. There is no clock-based promotion: silence never puts
308
+ work on the board.
309
+
310
+ Parameters
311
+ ----------
312
+ candidate : Candidate
313
+ The candidate, with its supporters and objectors.
314
+ now : str
315
+ The current time, ISO-8601.
316
+ veto_seconds : int
317
+ The window before an observation-born candidate promotes.
318
+ evaporation_seconds : int
319
+ The superseded seconds window, used only when ``declined_by`` is ``None``.
320
+ attention_seconds : Optional[float]
321
+ The superseded attention clock.
322
+ declined_by : Optional[int]
323
+ Distinct agents that were shown the candidate and then had a turn in
324
+ which they could have supported it and did not. When given, this is
325
+ what evaporates a candidate and nothing else does.
326
+ declines_needed : int
327
+ How many declines evaporate a candidate.
328
+ awaiting : Optional[int]
329
+ Distinct agents that could still answer. At zero the candidate is
330
+ settled whichever way it falls, so a contested one cannot hang.
331
+ settling_seconds : int
332
+ How long after support reaches the threshold before promotion, so a
333
+ late objection can still land. An objection inside the window blocks.
334
+
335
+ Returns
336
+ -------
337
+ str
338
+ """
339
+ age = candidate.age_seconds(now)
340
+ if declined_by is not None:
341
+ expired = declined_by >= declines_needed
342
+ else:
343
+ aged = attention_seconds if attention_seconds is not None else age
344
+ expired = aged > evaporation_seconds
345
+ exhausted = awaiting is not None and awaiting <= 0
346
+ observers = set(candidate.observers)
347
+ posted = {s for s in candidate.supporters if s not in observers}
348
+ objectors = len(set(candidate.objectors))
349
+ if len(posted) >= THRESHOLD and len(posted) > objectors:
350
+ if settling_seconds <= 0 or exhausted:
351
+ return "promote"
352
+ complete = candidate.support_complete_at()
353
+ if complete is None:
354
+ return "promote"
355
+ waited = (_when(now) - _when(complete)).total_seconds()
356
+ return "promote" if waited >= settling_seconds else "wait"
357
+ if (
358
+ len(set(candidate.supporters)) >= THRESHOLD
359
+ and len(set(candidate.supporters)) > objectors
360
+ ):
361
+ return "promote" if age >= veto_seconds else "wait"
362
+ return "evaporate" if expired or exhausted else "wait"
363
+
364
+
365
+ def can_post(open_count: int, crew: int) -> bool:
366
+ """Return whether another candidate may open: at most the crew size at once."""
367
+ return open_count < crew
368
+
369
+
370
+ def default_criterion(name: str) -> str:
371
+ """Return the completion criterion a spec gets when it names none."""
372
+ return (
373
+ f"The condition described in '{name}' holds, and the completion report"
374
+ " says what was done and carries the evidence for it"
375
+ )
376
+
377
+
378
+ def candidate_from_proposal(
379
+ entry: dict[str, Any], labels: Sequence[str] = (PLANNED_LABEL,)
380
+ ) -> tuple[dict[str, Any], str]:
381
+ """
382
+ Turn an agent's ``propose.*`` opinion into a candidate spec.
383
+
384
+ The proposer is the candidate's first supporter: the proposal is its
385
+ evidence. One more distinct agent promotes it.
386
+
387
+ Parameters
388
+ ----------
389
+ entry : Dict[str, Any]
390
+ The ``propose.*`` opinions entry.
391
+ labels : Sequence[str]
392
+ The labels the task carries; never a ``build``-family label.
393
+
394
+ Returns
395
+ -------
396
+ Tuple[Dict[str, Any], str]
397
+ The seed spec and the proposer.
398
+ """
399
+ subject = str(entry.get("subject") or "")
400
+ name = " ".join(subject[len(PROPOSAL_PREFIX) :].split(".")).strip() or "proposal"
401
+ opinions = sorted(
402
+ entry.get("opinions") or [], key=lambda o: str(o.get("timestamp") or "")
403
+ )
404
+ first = opinions[0] if opinions else {}
405
+ proposer = str(first.get("agent_id") or "")
406
+ description = str(first.get("what") or "").strip() or name
407
+ spec = {
408
+ "name": name,
409
+ "description": description,
410
+ "labels": list(labels),
411
+ "completion_criteria": [default_criterion(name)],
412
+ "estimated_hours": 0.4,
413
+ "priority": "medium",
414
+ "because": [subject],
415
+ # Not a create_tasks key, so it never reaches the board; it is the
416
+ # time the proposer's support landed, for the settling window.
417
+ "proposed_at": str(first.get("timestamp") or "").strip(),
418
+ }
419
+ return spec, proposer
420
+
421
+
422
+ def candidate_from_observation(
423
+ entry: dict[str, Any],
424
+ build_seed: Callable[[dict[str, Any]], dict[str, Any] | None],
425
+ labels: Sequence[str] = (PLANNED_LABEL,),
426
+ ) -> dict[str, Any] | None:
427
+ """
428
+ Turn an observation subject two distinct agents posted on into a candidate spec.
429
+
430
+ Parameters
431
+ ----------
432
+ entry : Dict[str, Any]
433
+ The observation's opinions entry, with ``agents``.
434
+ build_seed : Callable[[Dict[str, Any]], Dict[str, Any]]
435
+ The extension's rule for the task an observation becomes: its name,
436
+ description and fixed criteria. The toolkit has no domain to supply one.
437
+ labels : Sequence[str]
438
+ Labels every such task carries.
439
+
440
+ Returns
441
+ -------
442
+ Optional[Dict[str, Any]]
443
+ The spec, with the observers carried so they count as supporters and
444
+ the veto window applies; None when the extension has no card for it.
445
+ """
446
+ built = build_seed(entry)
447
+ if built is None:
448
+ return None
449
+ spec = dict(built)
450
+ have = [str(label) for label in (spec.get("labels") or [])]
451
+ for label in labels:
452
+ if label not in have:
453
+ have.append(label)
454
+ spec["labels"] = have
455
+ spec["observers"] = sorted({str(a) for a in (entry.get("agents") or []) if str(a)})
456
+ spec["because"] = [str(entry.get("subject"))]
457
+ if not spec.get("completion_criteria"):
458
+ spec["completion_criteria"] = [default_criterion(str(spec.get("name") or ""))]
459
+ return spec
460
+
461
+
462
+ def lineage_line(
463
+ spec: dict[str, Any], supporters: Iterable[str], authors: dict[str, list[str]]
464
+ ) -> str:
465
+ """
466
+ Render the paragraph a promoted task carries: its sources and its supporters.
467
+
468
+ Avoids the words plan, document, check, verify, test and resolve, which an
469
+ older kernel's phase classifier read as task phases.
470
+
471
+ Parameters
472
+ ----------
473
+ spec : Dict[str, Any]
474
+ The candidate's spec; ``because`` names its sources.
475
+ supporters : Iterable[str]
476
+ Who supported it.
477
+ authors : Dict[str, List[str]]
478
+ Marker subject to its authors.
479
+
480
+ Returns
481
+ -------
482
+ str
483
+ """
484
+ parts = []
485
+ for source in spec.get("because") or []:
486
+ who = authors.get(source)
487
+ parts.append(f"{source} ({', '.join(who)})" if who else str(source))
488
+ sources = ", ".join(parts) if parts else "the objective and the board"
489
+ return f"Lineage: from {sources}; supported by {', '.join(supporters)}"