okstra 0.127.0 → 0.129.0

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.
Files changed (53) hide show
  1. package/docs/architecture/storage-model.md +23 -3
  2. package/docs/architecture.md +28 -1
  3. package/docs/cli.md +9 -0
  4. package/docs/project-structure-overview.md +17 -7
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -3
  8. package/runtime/agents/workers/claude-worker.md +4 -4
  9. package/runtime/agents/workers/codex-worker.md +3 -3
  10. package/runtime/agents/workers/report-writer-worker.md +5 -5
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -1
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/convergence.md +45 -83
  15. package/runtime/prompts/lead/okstra-lead-contract.md +12 -8
  16. package/runtime/prompts/lead/report-writer.md +3 -2
  17. package/runtime/prompts/lead/team-contract.md +19 -9
  18. package/runtime/prompts/profiles/_common-contract.md +1 -1
  19. package/runtime/prompts/profiles/error-analysis.md +1 -1
  20. package/runtime/prompts/profiles/final-verification.md +10 -6
  21. package/runtime/prompts/profiles/implementation-planning.md +5 -0
  22. package/runtime/prompts/profiles/improvement-discovery.md +11 -1
  23. package/runtime/prompts/profiles/requirements-discovery.md +8 -1
  24. package/runtime/python/okstra_ctl/analysis_packet.py +14 -15
  25. package/runtime/python/okstra_ctl/codex_dispatch.py +51 -80
  26. package/runtime/python/okstra_ctl/context_cost.py +82 -7
  27. package/runtime/python/okstra_ctl/convergence.py +223 -0
  28. package/runtime/python/okstra_ctl/convergence_engine.py +1152 -0
  29. package/runtime/python/okstra_ctl/convergence_migration.py +184 -0
  30. package/runtime/python/okstra_ctl/convergence_store.py +85 -0
  31. package/runtime/python/okstra_ctl/dispatch_core.py +60 -1
  32. package/runtime/python/okstra_ctl/improvement_assignment.py +61 -0
  33. package/runtime/python/okstra_ctl/log_report.py +44 -5
  34. package/runtime/python/okstra_ctl/path_hints.py +28 -1
  35. package/runtime/python/okstra_ctl/paths.py +10 -1
  36. package/runtime/python/okstra_ctl/render.py +78 -11
  37. package/runtime/python/okstra_ctl/run.py +66 -2
  38. package/runtime/python/okstra_ctl/wizard.py +15 -4
  39. package/runtime/python/okstra_ctl/work_categories.py +37 -0
  40. package/runtime/python/okstra_ctl/worker_prompt_body.py +107 -0
  41. package/runtime/python/okstra_ctl/worker_prompt_contract.py +316 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_headers.py +109 -10
  43. package/runtime/python/okstra_ctl/worker_prompt_policy.py +158 -0
  44. package/runtime/templates/implementation-worker-preamble.md +51 -0
  45. package/runtime/templates/operating-standard.md +4 -4
  46. package/runtime/templates/report-writer-prompt-preamble.md +37 -0
  47. package/runtime/templates/worker-error-contract.md +46 -0
  48. package/runtime/templates/worker-prompt-preamble.md +28 -227
  49. package/runtime/validators/lib/fixtures.sh +27 -12
  50. package/runtime/validators/validate-run.py +228 -45
  51. package/src/cli-registry.mjs +7 -0
  52. package/src/commands/execute/convergence.mjs +34 -0
  53. package/src/commands/inspect/log-report.mjs +5 -3
@@ -0,0 +1,1152 @@
1
+ """Pure deterministic state transitions for Phase 5.5 convergence."""
2
+ from __future__ import annotations
3
+
4
+ from copy import deepcopy
5
+ import hashlib
6
+ import json
7
+ from typing import Any, Mapping
8
+
9
+
10
+ WORKING_SCHEMA_VERSION = "1.0"
11
+ FINAL_SCHEMA_VERSION = "1.2"
12
+ _AUDIENCES = {"analysis", "report-writer"}
13
+ _VERIFICATION_MODES = {"lightweight", "full-reanalysis"}
14
+ _CLASSIFICATION_COUNT_KEYS = {
15
+ "full-consensus": "fullConsensus",
16
+ "partial-consensus": "partialConsensus",
17
+ "contested": "contested",
18
+ "worker-unique": "workerUnique",
19
+ }
20
+ _VERDICTS = {"agree", "disagree", "supplement", "verification-error"}
21
+ _DISAGREE_BASES = {"counter-evidence", "burden-not-met"}
22
+ _DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
23
+
24
+
25
+ class ConvergenceContractError(ValueError):
26
+ """Raised when convergence input violates the persisted-state contract."""
27
+
28
+
29
+ def grouped_input_digest(grouped_input: Mapping[str, Any]) -> str:
30
+ """Return SHA-256 over canonical compact JSON."""
31
+ encoded = json.dumps(
32
+ grouped_input,
33
+ ensure_ascii=False,
34
+ sort_keys=True,
35
+ separators=(",", ":"),
36
+ ).encode("utf-8")
37
+ return hashlib.sha256(encoded).hexdigest()
38
+
39
+
40
+ def seed_working_state(grouped_input: Mapping[str, Any]) -> dict[str, Any]:
41
+ """Validate grouped findings, classify Round 0, and create the queue."""
42
+ source = _object(grouped_input, "grouped input")
43
+ if source.get("schemaVersion") != WORKING_SCHEMA_VERSION:
44
+ raise ConvergenceContractError("grouped input schemaVersion must be 1.0")
45
+ task_key = _required_string(source, "taskKey", "grouped input")
46
+ config = _parse_config(source.get("config"))
47
+ workers = _parse_workers(source.get("workers"))
48
+ analysis_workers = [
49
+ worker["workerId"] for worker in workers if worker["audience"] == "analysis"
50
+ ]
51
+ state = {
52
+ "schemaVersion": WORKING_SCHEMA_VERSION,
53
+ "taskKey": task_key,
54
+ "groupsDigest": grouped_input_digest(source),
55
+ "config": config,
56
+ "workers": workers,
57
+ "findings": [],
58
+ "queueFindingIds": [],
59
+ "roundHistory": [],
60
+ "stopReason": None,
61
+ }
62
+ if not config["enabled"] or len(analysis_workers) < 2:
63
+ state["stopReason"] = "auto-disabled"
64
+ return state
65
+
66
+ findings, queue = _parse_groups(source.get("groups"), analysis_workers)
67
+ state["findings"] = findings
68
+ state["queueFindingIds"] = queue
69
+ return state
70
+
71
+
72
+ def classification_counts_from_findings(
73
+ findings: list[Mapping[str, Any]],
74
+ ) -> dict[str, int]:
75
+ """Return the four v1.2 finalClassificationCounts keys."""
76
+ counts = {value: 0 for value in _CLASSIFICATION_COUNT_KEYS.values()}
77
+ for finding in findings:
78
+ key = _CLASSIFICATION_COUNT_KEYS.get(finding.get("classification"))
79
+ if key:
80
+ counts[key] += 1
81
+ return counts
82
+
83
+
84
+ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
85
+ """Return the next immutable roster-aware dispatch plan or final gate."""
86
+ current = _object(state, "working state")
87
+ queue, findings, history = _validate_plannable_state(current)
88
+ config = _object(current.get("config"), "working state.config")
89
+ effective_max = config.get("effectiveMaxRounds")
90
+ if not isinstance(effective_max, int):
91
+ raise ConvergenceContractError(
92
+ "working state.config.effectiveMaxRounds must be an integer"
93
+ )
94
+ stop_reason = current.get("stopReason")
95
+ reason = None
96
+ if stop_reason == "auto-disabled" or not config.get("enabled"):
97
+ reason = "auto-disabled"
98
+ elif stop_reason == "all-reverify-non-result":
99
+ reason = "all-reverify-non-result"
100
+ elif effective_max == 1 and history:
101
+ reason = "max-rounds-1"
102
+ elif not queue:
103
+ reason = "queue-empty"
104
+ elif len(history) >= effective_max:
105
+ reason = "max-rounds-reached"
106
+ round_number = len(history) + 1
107
+ if reason is not None:
108
+ return {
109
+ "schemaVersion": WORKING_SCHEMA_VERSION,
110
+ "action": "finalize",
111
+ "round": round_number,
112
+ "inputQueueSize": len(queue),
113
+ "reason": reason,
114
+ "dispatches": [],
115
+ "skippedWorkers": [],
116
+ }
117
+
118
+ workers = current.get("workers")
119
+ if not isinstance(workers, list):
120
+ raise ConvergenceContractError("working state.workers must be an array")
121
+ dispatches: list[dict[str, Any]] = []
122
+ skipped: list[dict[str, str]] = []
123
+ for worker in workers:
124
+ if not isinstance(worker, Mapping) or worker.get("audience") != "analysis":
125
+ continue
126
+ worker_id = _required_string(worker, "workerId", "working state worker")
127
+ eligible = [
128
+ finding_id
129
+ for finding_id in queue
130
+ if findings[finding_id].get("originWorker") != worker_id
131
+ ]
132
+ if eligible:
133
+ dispatches.append({"worker": worker_id, "findingIds": eligible})
134
+ else:
135
+ skipped.append(
136
+ {"worker": worker_id, "reason": "no items to verify"}
137
+ )
138
+ return {
139
+ "schemaVersion": WORKING_SCHEMA_VERSION,
140
+ "action": "dispatch",
141
+ "round": round_number,
142
+ "inputQueueSize": len(queue),
143
+ "dispatches": dispatches,
144
+ "skippedWorkers": skipped,
145
+ }
146
+
147
+
148
+ def classify_collaborative_round(
149
+ votes: Mapping[str, Mapping[str, Any]],
150
+ ) -> str | None:
151
+ """Classify one collaborative round using non-error votes only."""
152
+ parsed = _parsed_votes(votes, adversarial=False)
153
+ usable = [vote for vote in parsed.values() if vote["verdict"] != "verification-error"]
154
+ if not usable:
155
+ return None
156
+ agreeing = sum(vote["verdict"] in {"agree", "supplement"} for vote in usable)
157
+ disagreeing = sum(vote["verdict"] == "disagree" for vote in usable)
158
+ if agreeing == len(usable):
159
+ return "full-consensus"
160
+ if disagreeing == len(usable):
161
+ return "worker-unique"
162
+ if agreeing > len(usable) / 2:
163
+ return "partial-consensus"
164
+ return None
165
+
166
+
167
+ def classify_adversarial_round(
168
+ votes: Mapping[str, Mapping[str, Any]],
169
+ ) -> str | None:
170
+ """Classify one adversarial round using documented refutation bases."""
171
+ parsed = _parsed_votes(votes, adversarial=True)
172
+ usable = [vote for vote in parsed.values() if vote["verdict"] != "verification-error"]
173
+ if not usable:
174
+ return None
175
+ disagrees = [vote for vote in usable if vote["verdict"] == "disagree"]
176
+ if not disagrees:
177
+ return (
178
+ "partial-consensus"
179
+ if any(vote["verdict"] == "supplement" for vote in usable)
180
+ else "full-consensus"
181
+ )
182
+ if len(disagrees) == len(usable):
183
+ return "worker-unique"
184
+ if any(vote["disagreeBasis"] == "counter-evidence" for vote in disagrees):
185
+ return None
186
+ burden_count = sum(
187
+ vote["disagreeBasis"] == "burden-not-met" for vote in disagrees
188
+ )
189
+ if burden_count > len(usable) / 2:
190
+ return None
191
+ return "partial-consensus"
192
+
193
+
194
+ def apply_round_results(
195
+ state: Mapping[str, Any],
196
+ plan: Mapping[str, Any],
197
+ results: Mapping[str, Any],
198
+ ) -> dict[str, Any]:
199
+ """Validate and reduce one complete re-verification round."""
200
+ current = _object(state, "working state")
201
+ expected_plan = plan_next_round(current)
202
+ supplied_plan = _object(plan, "round plan")
203
+ if expected_plan.get("action") != "dispatch":
204
+ raise ConvergenceContractError("working state has no dispatch round to apply")
205
+ if supplied_plan != expected_plan:
206
+ raise ConvergenceContractError("round plan does not match current working state")
207
+ payload = _object(results, "round results")
208
+ if payload.get("schemaVersion") != WORKING_SCHEMA_VERSION:
209
+ raise ConvergenceContractError("round results schemaVersion must be 1.0")
210
+ if payload.get("round") != expected_plan["round"]:
211
+ raise ConvergenceContractError("round results round does not match plan round")
212
+
213
+ statuses = _parse_dispatch_results(payload.get("dispatches"), expected_plan)
214
+ raw_votes = _object(payload.get("votesByFinding"), "votesByFinding")
215
+ planned_by_finding = _planned_workers_by_finding(expected_plan)
216
+ votes_by_finding = _validate_round_votes(
217
+ raw_votes,
218
+ planned_by_finding,
219
+ statuses,
220
+ )
221
+ updated = deepcopy(dict(current))
222
+ queue = list(updated["queueFindingIds"])
223
+ findings = {row["findingId"]: row for row in updated["findings"]}
224
+ adversarial = bool(updated["config"].get("adversarial"))
225
+ resolved: list[str] = []
226
+ for finding_id in queue:
227
+ finding = findings[finding_id]
228
+ round_votes = _votes_for_finding(
229
+ finding_id,
230
+ planned_by_finding,
231
+ statuses,
232
+ votes_by_finding,
233
+ adversarial,
234
+ )
235
+ finding["rounds"].append(
236
+ {"round": expected_plan["round"], "votes": round_votes}
237
+ )
238
+ classification = (
239
+ classify_adversarial_round(round_votes)
240
+ if adversarial
241
+ else classify_collaborative_round(round_votes)
242
+ )
243
+ if classification is not None:
244
+ finding["classification"] = classification
245
+ resolved.append(finding_id)
246
+ _recompute_worker_positions(finding, updated["workers"])
247
+ updated["queueFindingIds"] = [
248
+ finding_id for finding_id in queue if finding_id not in set(resolved)
249
+ ]
250
+ dispatch_history = [
251
+ {
252
+ "worker": row["worker"],
253
+ "status": statuses[row["worker"]]["status"],
254
+ "durationMs": statuses[row["worker"]]["durationMs"],
255
+ }
256
+ for row in expected_plan["dispatches"]
257
+ ]
258
+ skipped = deepcopy(expected_plan["skippedWorkers"])
259
+ for row in dispatch_history:
260
+ if row["status"] != "completed":
261
+ skipped.append(
262
+ {
263
+ "worker": row["worker"],
264
+ "reason": "dispatch-non-result",
265
+ "terminalStatus": row["status"],
266
+ }
267
+ )
268
+ updated["roundHistory"].append(
269
+ {
270
+ "round": expected_plan["round"],
271
+ "inputQueueSize": len(queue),
272
+ "resolvedCount": len(resolved),
273
+ "carriedForwardCount": len(updated["queueFindingIds"]),
274
+ "dispatches": dispatch_history,
275
+ "skippedWorkers": skipped,
276
+ }
277
+ )
278
+ if dispatch_history and all(
279
+ row["status"] != "completed" for row in dispatch_history
280
+ ):
281
+ updated["stopReason"] = "all-reverify-non-result"
282
+ return updated
283
+
284
+
285
+ def finalize_working_state(state: Mapping[str, Any]) -> dict[str, Any]:
286
+ """Return the public schema-v1.2 convergence artifact."""
287
+ errors = validate_working_state(state)
288
+ if errors:
289
+ raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
290
+ current = deepcopy(dict(state))
291
+ config = deepcopy(current["config"])
292
+ findings = deepcopy(current["findings"])
293
+ queue = set(current["queueFindingIds"])
294
+ adversarial = bool(config.get("adversarial"))
295
+ for finding in findings:
296
+ if finding["findingId"] not in queue:
297
+ continue
298
+ finding["classification"] = (
299
+ "contested"
300
+ if adversarial
301
+ else _final_collaborative_classification(finding)
302
+ )
303
+ stop_reason = current.get("stopReason")
304
+ history = deepcopy(current["roundHistory"])
305
+ if stop_reason == "auto-disabled":
306
+ config["autoDisabled"] = "fewer-than-two-analysers"
307
+ skip_reason = "auto-disabled"
308
+ final_state = "converged"
309
+ elif config["effectiveMaxRounds"] == 1 and history:
310
+ skip_reason = "max-rounds-1"
311
+ if stop_reason == "all-reverify-non-result":
312
+ final_state = "aborted-non-result"
313
+ elif not queue:
314
+ final_state = "converged"
315
+ else:
316
+ final_state = "max-rounds-reached"
317
+ elif stop_reason == "all-reverify-non-result":
318
+ skip_reason = "all-reverify-non-result"
319
+ final_state = "aborted-non-result"
320
+ elif len(history) >= 2:
321
+ skip_reason = "not-skipped"
322
+ final_state = "converged" if not queue else "max-rounds-reached"
323
+ elif not queue:
324
+ skip_reason = "queue-empty"
325
+ final_state = "converged"
326
+ else:
327
+ skip_reason = "not-skipped"
328
+ final_state = "max-rounds-reached"
329
+ final = {
330
+ "schemaVersion": FINAL_SCHEMA_VERSION,
331
+ "taskKey": current["taskKey"],
332
+ "config": config,
333
+ "findings": findings,
334
+ "roundHistory": history,
335
+ "round2SkippedReason": skip_reason,
336
+ "finalState": final_state,
337
+ "totalRounds": len(history),
338
+ "finalClassificationCounts": classification_counts_from_findings(findings),
339
+ }
340
+ final_errors = validate_final_state(final)
341
+ if final_errors:
342
+ raise ConvergenceContractError(
343
+ "finalized convergence state is invalid: " + "; ".join(final_errors)
344
+ )
345
+ return final
346
+
347
+
348
+ def validate_working_state(state: Mapping[str, Any]) -> list[str]:
349
+ """Return structural and transition errors for a working-state object."""
350
+ errors: list[str] = []
351
+ if not isinstance(state, Mapping):
352
+ return ["working state must be an object"]
353
+ if state.get("schemaVersion") != WORKING_SCHEMA_VERSION:
354
+ errors.append("working schemaVersion must be 1.0")
355
+ if not _nonempty_string(state.get("taskKey")):
356
+ errors.append("taskKey must be a non-empty string")
357
+ digest = state.get("groupsDigest")
358
+ if not isinstance(digest, str) or len(digest) != 64:
359
+ errors.append("groupsDigest must be a SHA-256 hex digest")
360
+ config = state.get("config")
361
+ if not isinstance(config, Mapping):
362
+ errors.append("config must be an object")
363
+ workers = state.get("workers")
364
+ if not isinstance(workers, list):
365
+ errors.append("workers must be an array")
366
+ workers = []
367
+ findings = state.get("findings")
368
+ if not isinstance(findings, list):
369
+ errors.append("findings must be an array")
370
+ findings = []
371
+ queue = state.get("queueFindingIds")
372
+ if not isinstance(queue, list):
373
+ errors.append("queueFindingIds must be an array")
374
+ queue = []
375
+ elif len(queue) != len(set(queue)):
376
+ errors.append("duplicate queue finding ID")
377
+ history = state.get("roundHistory")
378
+ if not isinstance(history, list):
379
+ errors.append("roundHistory must be an array")
380
+ history = []
381
+ errors.extend(_validate_round_history(history))
382
+ by_id: dict[str, Mapping[str, Any]] = {}
383
+ for finding in findings:
384
+ if not isinstance(finding, Mapping):
385
+ errors.append("finding must be an object")
386
+ continue
387
+ finding_id = finding.get("findingId")
388
+ if not _nonempty_string(finding_id):
389
+ errors.append("findingId must be a non-empty string")
390
+ continue
391
+ if finding_id in by_id:
392
+ errors.append(f"duplicate findingId: {finding_id}")
393
+ by_id[str(finding_id)] = finding
394
+ errors.extend(_validate_finding_ledger(finding, len(history), bool(config and config.get("adversarial"))))
395
+ for finding_id in queue:
396
+ if finding_id not in by_id:
397
+ errors.append(f"queue references missing finding: {finding_id}")
398
+ elif by_id[finding_id].get("classification") is not None:
399
+ errors.append(f"queued finding is already classified: {finding_id}")
400
+ for finding_id, finding in by_id.items():
401
+ if finding.get("classification") is None and finding_id not in queue:
402
+ errors.append(f"unclassified finding is absent from queue: {finding_id}")
403
+ if history and history[-1].get("carriedForwardCount") != len(queue):
404
+ errors.append("last round carriedForwardCount disagrees with queue length")
405
+ if state.get("stopReason") not in {None, "auto-disabled", "all-reverify-non-result"}:
406
+ errors.append("stopReason is unsupported")
407
+ return errors
408
+
409
+
410
+ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
411
+ """Validate historical v1.0/v1.1 and current v1.2 final artifacts."""
412
+ errors: list[str] = []
413
+ if not isinstance(state, Mapping):
414
+ return ["final state must be an object"]
415
+ if state.get("schemaVersion") not in {"1.0", "1.1", "1.2"}:
416
+ errors.append("unsupported final schemaVersion")
417
+ config = state.get("config")
418
+ if not isinstance(config, Mapping):
419
+ errors.append("config must be an object")
420
+ config = {}
421
+ effective = config.get("effectiveMaxRounds")
422
+ if not isinstance(effective, int) or isinstance(effective, bool) or not 1 <= effective <= 3:
423
+ errors.append("config.effectiveMaxRounds must be an integer from 1 to 3")
424
+ findings = state.get("findings")
425
+ if not isinstance(findings, list):
426
+ errors.append("findings must be an array")
427
+ findings = []
428
+ history = state.get("roundHistory")
429
+ if not isinstance(history, list):
430
+ errors.append("roundHistory must be an array")
431
+ history = []
432
+ errors.extend(_validate_round_history(history))
433
+ adversarial = bool(config.get("adversarial"))
434
+ seen_ids: set[str] = set()
435
+ for finding in findings:
436
+ if not isinstance(finding, Mapping):
437
+ errors.append("finding must be an object")
438
+ continue
439
+ finding_id = finding.get("findingId")
440
+ if _nonempty_string(finding_id):
441
+ if finding_id in seen_ids:
442
+ errors.append(f"duplicate findingId: {finding_id}")
443
+ seen_ids.add(str(finding_id))
444
+ classification = finding.get("classification")
445
+ if classification not in _CLASSIFICATION_COUNT_KEYS:
446
+ errors.append(f"finding {finding_id} has invalid classification")
447
+ errors.extend(_validate_finding_ledger(finding, len(history), adversarial))
448
+ rounds = finding.get("rounds") if isinstance(finding.get("rounds"), list) else []
449
+ if classification == "contested" and rounds:
450
+ if rounds[-1].get("round") != len(history):
451
+ errors.append(f"finding {finding_id} contested before the final round")
452
+ if classification != "contested":
453
+ errors.extend(_validate_no_reappearance_after_resolution(finding, adversarial))
454
+ expected = _expected_final_classification(finding, adversarial)
455
+ if expected is not None and classification != expected:
456
+ errors.append(
457
+ f"finding {finding_id} classification {classification} "
458
+ f"does not match replayed classification {expected}"
459
+ )
460
+ errors.extend(_validate_round_ledger_counts(findings, history, adversarial))
461
+ total_rounds = state.get("totalRounds")
462
+ if total_rounds != len(history):
463
+ errors.append("totalRounds does not match roundHistory length")
464
+ declared = state.get("finalClassificationCounts")
465
+ if not isinstance(declared, Mapping):
466
+ errors.append("finalClassificationCounts must be an object")
467
+ else:
468
+ actual = classification_counts_from_findings(findings)
469
+ if dict(declared) != actual:
470
+ errors.append(
471
+ f"finalClassificationCounts {dict(declared)} does not match findings {actual}"
472
+ )
473
+ reason = state.get("round2SkippedReason")
474
+ errors.extend(
475
+ _validate_final_reason(
476
+ reason,
477
+ state.get("finalState"),
478
+ history,
479
+ effective,
480
+ config,
481
+ )
482
+ )
483
+ return errors
484
+
485
+
486
+ def _final_collaborative_classification(finding: Mapping[str, Any]) -> str:
487
+ usable: list[Mapping[str, Any]] = []
488
+ for round_row in finding.get("rounds", []):
489
+ for vote in round_row.get("votes", {}).values():
490
+ if vote.get("verdict") != "verification-error":
491
+ usable.append(vote)
492
+ agreeing = sum(vote.get("verdict") in {"agree", "supplement"} for vote in usable)
493
+ return "partial-consensus" if usable and agreeing > len(usable) / 2 else "contested"
494
+
495
+
496
+ def _expected_final_classification(
497
+ finding: Mapping[str, Any],
498
+ adversarial: bool,
499
+ ) -> str | None:
500
+ rounds = finding.get("rounds")
501
+ if not isinstance(rounds, list) or not rounds:
502
+ return None
503
+ for row in rounds:
504
+ if not isinstance(row, Mapping) or not isinstance(row.get("votes"), Mapping):
505
+ continue
506
+ try:
507
+ resolved = (
508
+ classify_adversarial_round(row["votes"])
509
+ if adversarial
510
+ else classify_collaborative_round(row["votes"])
511
+ )
512
+ except ConvergenceContractError:
513
+ continue
514
+ if resolved is not None:
515
+ return resolved
516
+ return "contested" if adversarial else _final_collaborative_classification(finding)
517
+
518
+
519
+ def _validate_round_ledger_counts(
520
+ findings: list[Any],
521
+ history: list[Any],
522
+ adversarial: bool,
523
+ ) -> list[str]:
524
+ errors: list[str] = []
525
+ for round_number, history_row in enumerate(history, start=1):
526
+ if not isinstance(history_row, Mapping):
527
+ continue
528
+ ledgers = _round_ledgers(findings, round_number)
529
+ resolved = _resolved_ledger_count(ledgers, adversarial)
530
+ expected = (len(ledgers), resolved, len(ledgers) - resolved)
531
+ actual = (
532
+ history_row.get("inputQueueSize"),
533
+ history_row.get("resolvedCount"),
534
+ history_row.get("carriedForwardCount"),
535
+ )
536
+ if actual != expected:
537
+ errors.append(
538
+ f"round {round_number} counters {actual} do not match finding ledgers {expected}"
539
+ )
540
+ return errors
541
+
542
+
543
+ def _round_ledgers(
544
+ findings: list[Any],
545
+ round_number: int,
546
+ ) -> list[Mapping[str, Any]]:
547
+ ledgers: list[Mapping[str, Any]] = []
548
+ for finding in findings:
549
+ if not isinstance(finding, Mapping):
550
+ continue
551
+ rounds = finding.get("rounds")
552
+ if not isinstance(rounds, list):
553
+ continue
554
+ ledgers.extend(
555
+ row
556
+ for row in rounds
557
+ if isinstance(row, Mapping) and row.get("round") == round_number
558
+ )
559
+ return ledgers
560
+
561
+
562
+ def _resolved_ledger_count(
563
+ ledgers: list[Mapping[str, Any]],
564
+ adversarial: bool,
565
+ ) -> int:
566
+ resolved = 0
567
+ for row in ledgers:
568
+ votes = row.get("votes")
569
+ if not isinstance(votes, Mapping):
570
+ continue
571
+ try:
572
+ classification = (
573
+ classify_adversarial_round(votes)
574
+ if adversarial
575
+ else classify_collaborative_round(votes)
576
+ )
577
+ except ConvergenceContractError:
578
+ continue
579
+ resolved += classification is not None
580
+ return resolved
581
+
582
+
583
+ def _validate_round_history(history: list[Any]) -> list[str]:
584
+ errors: list[str] = []
585
+ previous_carry = None
586
+ for index, row in enumerate(history, start=1):
587
+ if not isinstance(row, Mapping):
588
+ errors.append(f"roundHistory[{index - 1}] must be an object")
589
+ continue
590
+ if row.get("round") != index:
591
+ errors.append("roundHistory has duplicate or gapped round numbers")
592
+ input_size = row.get("inputQueueSize")
593
+ resolved = row.get("resolvedCount")
594
+ carried = row.get("carriedForwardCount")
595
+ if not all(isinstance(value, int) for value in (input_size, resolved, carried)):
596
+ errors.append(f"round {index} counters must be integers")
597
+ elif input_size != resolved + carried:
598
+ errors.append(f"round arithmetic mismatch at round {index}")
599
+ if previous_carry is not None and input_size != previous_carry:
600
+ errors.append(f"next-round input mismatch at round {index}")
601
+ previous_carry = carried
602
+ dispatches = row.get("dispatches")
603
+ if not isinstance(dispatches, list):
604
+ errors.append(f"round {index} dispatches must be an array")
605
+ else:
606
+ for dispatch in dispatches:
607
+ if not isinstance(dispatch, Mapping) or dispatch.get("status") not in _DISPATCH_STATUSES:
608
+ errors.append(f"round {index} has invalid dispatch status")
609
+ return errors
610
+
611
+
612
+ def _validate_finding_ledger(
613
+ finding: Mapping[str, Any],
614
+ total_rounds: int,
615
+ adversarial: bool,
616
+ ) -> list[str]:
617
+ errors: list[str] = []
618
+ finding_id = finding.get("findingId")
619
+ origin = finding.get("originWorker")
620
+ rounds = finding.get("rounds")
621
+ if not isinstance(rounds, list):
622
+ return [f"finding {finding_id} rounds must be an array"]
623
+ expected_round = 1
624
+ for row in rounds:
625
+ if not isinstance(row, Mapping):
626
+ errors.append(f"finding {finding_id} round ledger must be an object")
627
+ continue
628
+ round_number = row.get("round")
629
+ if round_number != expected_round:
630
+ errors.append(f"finding {finding_id} has duplicate or gapped round ledger")
631
+ expected_round += 1
632
+ if not isinstance(round_number, int) or round_number > total_rounds:
633
+ errors.append(f"finding {finding_id} references a non-executed round")
634
+ votes = row.get("votes")
635
+ if not isinstance(votes, Mapping):
636
+ errors.append(f"finding {finding_id} votes must be an object")
637
+ continue
638
+ if origin in votes:
639
+ errors.append(f"finding {finding_id} contains an origin worker vote")
640
+ for worker, vote in votes.items():
641
+ try:
642
+ _parse_vote(vote, adversarial=adversarial)
643
+ except ConvergenceContractError as exc:
644
+ errors.append(f"finding {finding_id} vote {worker}: {exc}")
645
+ return errors
646
+
647
+
648
+ def _validate_no_reappearance_after_resolution(
649
+ finding: Mapping[str, Any],
650
+ adversarial: bool,
651
+ ) -> list[str]:
652
+ rounds = finding.get("rounds")
653
+ if not isinstance(rounds, list):
654
+ return []
655
+ for index, row in enumerate(rounds[:-1]):
656
+ votes = row.get("votes") if isinstance(row, Mapping) else None
657
+ if not isinstance(votes, Mapping):
658
+ continue
659
+ try:
660
+ classification = (
661
+ classify_adversarial_round(votes)
662
+ if adversarial
663
+ else classify_collaborative_round(votes)
664
+ )
665
+ except ConvergenceContractError:
666
+ continue
667
+ if classification is not None:
668
+ return [
669
+ f"finding {finding.get('findingId')} reappears after resolution in round {index + 1}"
670
+ ]
671
+ return []
672
+
673
+
674
+ def _validate_final_reason(
675
+ reason: Any,
676
+ final_state: Any,
677
+ history: list[Any],
678
+ effective_max: Any,
679
+ config: Mapping[str, Any],
680
+ ) -> list[str]:
681
+ errors: list[str] = []
682
+ allowed = {
683
+ "auto-disabled",
684
+ "queue-empty",
685
+ "max-rounds-1",
686
+ "all-reverify-non-result",
687
+ "not-skipped",
688
+ }
689
+ if reason not in allowed:
690
+ return ["round2SkippedReason is unsupported"]
691
+ last_dispatches = history[-1].get("dispatches", []) if history else []
692
+ all_non_result = bool(last_dispatches) and all(
693
+ isinstance(row, Mapping) and row.get("status") != "completed"
694
+ for row in last_dispatches
695
+ )
696
+ auto_disabled = (
697
+ config.get("enabled") is False
698
+ or config.get("autoDisabled") == "fewer-than-two-analysers"
699
+ )
700
+ expected_reason = None
701
+ if auto_disabled:
702
+ expected_reason = "auto-disabled"
703
+ elif effective_max == 1 and history:
704
+ expected_reason = "max-rounds-1"
705
+ elif all_non_result:
706
+ expected_reason = "all-reverify-non-result"
707
+ elif len(history) >= 2:
708
+ expected_reason = "not-skipped"
709
+ elif not history or history[-1].get("carriedForwardCount") == 0:
710
+ expected_reason = "queue-empty"
711
+ if expected_reason is not None and reason != expected_reason:
712
+ errors.append(
713
+ f"round2SkippedReason {reason} does not match replayed reason {expected_reason}"
714
+ )
715
+ queue_drained = bool(history) and history[-1].get("carriedForwardCount") == 0
716
+ if all_non_result:
717
+ expected_state = "aborted-non-result"
718
+ elif reason in {"auto-disabled", "queue-empty"} or queue_drained:
719
+ expected_state = "converged"
720
+ else:
721
+ expected_state = "max-rounds-reached"
722
+ if reason == "all-reverify-non-result" and not all_non_result:
723
+ errors.append("all-reverify-non-result requires only non-result dispatches")
724
+ if reason == "max-rounds-1" and effective_max != 1:
725
+ errors.append("max-rounds-1 requires effectiveMaxRounds=1")
726
+ if reason == "queue-empty" and history and history[-1].get("carriedForwardCount") != 0:
727
+ errors.append("queue-empty requires zero carriedForwardCount")
728
+ if reason == "not-skipped" and len(history) < 2:
729
+ errors.append("not-skipped requires at least two rounds")
730
+ if final_state != expected_state:
731
+ errors.append(
732
+ f"finalState {final_state} is inconsistent with round2SkippedReason {reason}"
733
+ )
734
+ return errors
735
+
736
+
737
+ def _nonempty_string(value: Any) -> bool:
738
+ return isinstance(value, str) and bool(value.strip())
739
+
740
+
741
+ def _parse_dispatch_results(
742
+ value: Any,
743
+ plan: Mapping[str, Any],
744
+ ) -> dict[str, dict[str, Any]]:
745
+ if not isinstance(value, list):
746
+ raise ConvergenceContractError("round results dispatches must be an array")
747
+ planned_workers = [row["worker"] for row in plan["dispatches"]]
748
+ statuses: dict[str, dict[str, Any]] = {}
749
+ for index, raw in enumerate(value):
750
+ row = _object(raw, f"round results dispatches[{index}]")
751
+ worker = _required_string(row, "worker", f"round results dispatches[{index}]")
752
+ if worker in statuses:
753
+ raise ConvergenceContractError(f"duplicate dispatch result: {worker}")
754
+ if worker not in planned_workers:
755
+ raise ConvergenceContractError(f"unplanned worker in dispatch results: {worker}")
756
+ status = _required_string(row, "status", f"dispatch {worker}")
757
+ if status not in _DISPATCH_STATUSES:
758
+ raise ConvergenceContractError(f"dispatch {worker} has invalid status")
759
+ duration = row.get("durationMs")
760
+ if not isinstance(duration, int) or isinstance(duration, bool) or duration < 0:
761
+ raise ConvergenceContractError(
762
+ f"dispatch {worker}.durationMs must be a non-negative integer"
763
+ )
764
+ statuses[worker] = {"status": status, "durationMs": duration}
765
+ missing = [worker for worker in planned_workers if worker not in statuses]
766
+ if missing:
767
+ raise ConvergenceContractError(
768
+ "missing status for planned dispatch: " + ", ".join(missing)
769
+ )
770
+ return statuses
771
+
772
+
773
+ def _planned_workers_by_finding(
774
+ plan: Mapping[str, Any],
775
+ ) -> dict[str, list[str]]:
776
+ planned: dict[str, list[str]] = {}
777
+ for row in plan["dispatches"]:
778
+ for finding_id in row["findingIds"]:
779
+ planned.setdefault(finding_id, []).append(row["worker"])
780
+ return planned
781
+
782
+
783
+ def _validate_round_votes(
784
+ raw_votes: Mapping[str, Any],
785
+ planned_by_finding: Mapping[str, list[str]],
786
+ statuses: Mapping[str, Mapping[str, Any]],
787
+ ) -> dict[str, dict[str, Mapping[str, Any]]]:
788
+ parsed: dict[str, dict[str, Mapping[str, Any]]] = {}
789
+ for finding_id, worker_votes_value in raw_votes.items():
790
+ if finding_id not in planned_by_finding:
791
+ raise ConvergenceContractError(f"unplanned finding in votes: {finding_id}")
792
+ worker_votes = _object(worker_votes_value, f"votesByFinding.{finding_id}")
793
+ parsed[finding_id] = {}
794
+ for worker, vote_value in worker_votes.items():
795
+ if worker not in planned_by_finding[finding_id]:
796
+ raise ConvergenceContractError(
797
+ f"origin worker or unplanned worker voted on {finding_id}: {worker}"
798
+ )
799
+ if statuses[worker]["status"] != "completed":
800
+ raise ConvergenceContractError(
801
+ f"non-result dispatch supplied a vote: {worker} {finding_id}"
802
+ )
803
+ parsed[finding_id][worker] = _object(
804
+ vote_value, f"votesByFinding.{finding_id}.{worker}"
805
+ )
806
+ for finding_id, workers in planned_by_finding.items():
807
+ for worker in workers:
808
+ if statuses[worker]["status"] == "completed" and worker not in parsed.get(
809
+ finding_id, {}
810
+ ):
811
+ raise ConvergenceContractError(
812
+ f"missing vote for completed worker {worker} on {finding_id}"
813
+ )
814
+ return parsed
815
+
816
+
817
+ def _votes_for_finding(
818
+ finding_id: str,
819
+ planned_by_finding: Mapping[str, list[str]],
820
+ statuses: Mapping[str, Mapping[str, Any]],
821
+ votes_by_finding: Mapping[str, Mapping[str, Mapping[str, Any]]],
822
+ adversarial: bool,
823
+ ) -> dict[str, dict[str, Any]]:
824
+ votes: dict[str, dict[str, Any]] = {}
825
+ for worker in planned_by_finding.get(finding_id, []):
826
+ status = statuses[worker]["status"]
827
+ if status == "completed":
828
+ votes[worker] = _parse_vote(
829
+ votes_by_finding[finding_id][worker], adversarial=adversarial
830
+ )
831
+ else:
832
+ votes[worker] = {
833
+ "verdict": "verification-error",
834
+ "disagreeBasis": None,
835
+ "explanation": f"dispatch ended with terminal status {status}",
836
+ }
837
+ return votes
838
+
839
+
840
+ def _recompute_worker_positions(
841
+ finding: dict[str, Any],
842
+ workers: list[Mapping[str, Any]],
843
+ ) -> None:
844
+ source_workers = set(finding.get("discoveredBy", {}).keys())
845
+ consensus = set(source_workers)
846
+ dissent = set()
847
+ for round_row in finding.get("rounds", []):
848
+ for worker, vote in round_row.get("votes", {}).items():
849
+ if vote.get("verdict") in {"agree", "supplement"}:
850
+ consensus.add(worker)
851
+ dissent.discard(worker)
852
+ elif vote.get("verdict") == "disagree":
853
+ dissent.add(worker)
854
+ consensus.discard(worker)
855
+ roster = [
856
+ worker.get("workerId")
857
+ for worker in workers
858
+ if worker.get("audience") == "analysis"
859
+ ]
860
+ finding["consensusWorkers"] = [worker for worker in roster if worker in consensus]
861
+ finding["dissentingWorkers"] = [worker for worker in roster if worker in dissent]
862
+
863
+
864
+ def _parsed_votes(
865
+ votes: Mapping[str, Mapping[str, Any]],
866
+ *,
867
+ adversarial: bool,
868
+ ) -> dict[str, dict[str, Any]]:
869
+ if not isinstance(votes, Mapping):
870
+ raise ConvergenceContractError("votes must be an object")
871
+ return {
872
+ worker: _parse_vote(vote, adversarial=adversarial)
873
+ for worker, vote in votes.items()
874
+ }
875
+
876
+
877
+ def _parse_vote(value: Any, *, adversarial: bool) -> dict[str, Any]:
878
+ vote = _object(value, "vote")
879
+ verdict = _required_string(vote, "verdict", "vote")
880
+ if verdict not in _VERDICTS:
881
+ raise ConvergenceContractError(f"unsupported vote verdict: {verdict}")
882
+ explanation = _required_string(vote, "explanation", "vote")
883
+ basis = vote.get("disagreeBasis")
884
+ if verdict == "disagree" and adversarial:
885
+ if basis not in _DISAGREE_BASES:
886
+ raise ConvergenceContractError(
887
+ "adversarial disagree vote requires disagreeBasis"
888
+ )
889
+ elif basis is not None:
890
+ raise ConvergenceContractError(
891
+ "disagreeBasis is allowed only for adversarial disagree votes"
892
+ )
893
+ return {
894
+ "verdict": verdict,
895
+ "disagreeBasis": basis,
896
+ "explanation": explanation,
897
+ }
898
+
899
+
900
+ def _validate_plannable_state(
901
+ state: Mapping[str, Any],
902
+ ) -> tuple[list[str], dict[str, Mapping[str, Any]], list[Mapping[str, Any]]]:
903
+ if state.get("schemaVersion") != WORKING_SCHEMA_VERSION:
904
+ raise ConvergenceContractError("working state schemaVersion must be 1.0")
905
+ queue_value = state.get("queueFindingIds")
906
+ if not isinstance(queue_value, list) or not all(
907
+ isinstance(item, str) and item for item in queue_value
908
+ ):
909
+ raise ConvergenceContractError(
910
+ "working state.queueFindingIds must be a string array"
911
+ )
912
+ queue = list(queue_value)
913
+ if len(queue) != len(set(queue)):
914
+ raise ConvergenceContractError("duplicate queue finding ID")
915
+ finding_rows = state.get("findings")
916
+ if not isinstance(finding_rows, list):
917
+ raise ConvergenceContractError("working state.findings must be an array")
918
+ findings: dict[str, Mapping[str, Any]] = {}
919
+ for row in finding_rows:
920
+ finding = _object(row, "working state finding")
921
+ finding_id = _required_string(finding, "findingId", "working state finding")
922
+ if finding_id in findings:
923
+ raise ConvergenceContractError(f"duplicate findingId: {finding_id}")
924
+ findings[finding_id] = finding
925
+ for finding_id in queue:
926
+ finding = findings.get(finding_id)
927
+ if finding is None:
928
+ raise ConvergenceContractError(
929
+ f"queue references missing finding: {finding_id}"
930
+ )
931
+ if finding.get("classification") is not None:
932
+ raise ConvergenceContractError(
933
+ f"queued finding is already classified: {finding_id}"
934
+ )
935
+ history_value = state.get("roundHistory")
936
+ if not isinstance(history_value, list):
937
+ raise ConvergenceContractError("working state.roundHistory must be an array")
938
+ history = [_object(row, "round history row") for row in history_value]
939
+ if history:
940
+ carried = history[-1].get("carriedForwardCount")
941
+ if carried != len(queue):
942
+ raise ConvergenceContractError(
943
+ "last round carriedForwardCount disagrees with current queue length"
944
+ )
945
+ return queue, findings, history
946
+
947
+
948
+ def _parse_config(value: Any) -> dict[str, Any]:
949
+ config = _object(value, "config")
950
+ enabled = config.get("enabled")
951
+ adversarial = config.get("adversarial")
952
+ if not isinstance(enabled, bool):
953
+ raise ConvergenceContractError("config.enabled must be boolean")
954
+ if not isinstance(adversarial, bool):
955
+ raise ConvergenceContractError("config.adversarial must be boolean")
956
+ max_rounds = _round_limit(config.get("maxRounds"), "maxRounds")
957
+ effective = _round_limit(
958
+ config.get("effectiveMaxRounds"), "effectiveMaxRounds"
959
+ )
960
+ if effective > max_rounds:
961
+ raise ConvergenceContractError(
962
+ "config.effectiveMaxRounds cannot exceed maxRounds"
963
+ )
964
+ mode = _required_string(config, "verificationMode", "config")
965
+ if mode not in _VERIFICATION_MODES:
966
+ raise ConvergenceContractError("config.verificationMode is unsupported")
967
+ if adversarial and mode != "full-reanalysis":
968
+ raise ConvergenceContractError(
969
+ "adversarial convergence requires full-reanalysis verificationMode"
970
+ )
971
+ return {
972
+ "enabled": enabled,
973
+ "adversarial": adversarial,
974
+ "maxRounds": max_rounds,
975
+ "effectiveMaxRounds": effective,
976
+ "verificationMode": mode,
977
+ }
978
+
979
+
980
+ def _parse_workers(value: Any) -> list[dict[str, str]]:
981
+ if not isinstance(value, list):
982
+ raise ConvergenceContractError("workers must be an array")
983
+ workers: list[dict[str, str]] = []
984
+ seen: set[str] = set()
985
+ for index, raw in enumerate(value):
986
+ worker = _object(raw, f"workers[{index}]")
987
+ worker_id = _required_string(worker, "workerId", f"workers[{index}]")
988
+ audience = _required_string(worker, "audience", f"workers[{index}]")
989
+ if audience not in _AUDIENCES:
990
+ raise ConvergenceContractError(
991
+ f"workers[{index}] has unsupported audience: {audience}"
992
+ )
993
+ if worker_id in seen:
994
+ raise ConvergenceContractError(f"duplicate workerId: {worker_id}")
995
+ seen.add(worker_id)
996
+ workers.append({"workerId": worker_id, "audience": audience})
997
+ return workers
998
+
999
+
1000
+ def _parse_groups(
1001
+ value: Any,
1002
+ analysis_workers: list[str],
1003
+ ) -> tuple[list[dict[str, Any]], list[str]]:
1004
+ if not isinstance(value, list):
1005
+ raise ConvergenceContractError("groups must be an array")
1006
+ findings: list[dict[str, Any]] = []
1007
+ queue: list[str] = []
1008
+ seen_ids: set[str] = set()
1009
+ for index, raw in enumerate(value):
1010
+ group = _object(raw, f"groups[{index}]")
1011
+ finding_id = _required_string(group, "findingId", f"groups[{index}]")
1012
+ if finding_id in seen_ids:
1013
+ raise ConvergenceContractError(f"duplicate findingId: {finding_id}")
1014
+ seen_ids.add(finding_id)
1015
+ finding, source_workers = _parse_group(group, index, analysis_workers)
1016
+ if len(source_workers) >= 2:
1017
+ finding["classification"] = "full-consensus"
1018
+ else:
1019
+ queue.append(finding_id)
1020
+ findings.append(finding)
1021
+ return findings, queue
1022
+
1023
+
1024
+ def _parse_group(
1025
+ group: Mapping[str, Any],
1026
+ index: int,
1027
+ analysis_workers: list[str],
1028
+ ) -> tuple[dict[str, Any], list[str]]:
1029
+ label = f"groups[{index}]"
1030
+ origin = _required_string(group, "originWorker", label)
1031
+ if origin not in analysis_workers:
1032
+ raise ConvergenceContractError(
1033
+ f"{label}.originWorker is outside the analysis roster: {origin}"
1034
+ )
1035
+ origin_evidence = _required_string(group, "originEvidence", label)
1036
+ discovered_by = _parse_discovered_by(
1037
+ group.get("discoveredBy"), label, analysis_workers
1038
+ )
1039
+ if origin not in discovered_by:
1040
+ raise ConvergenceContractError(
1041
+ f"{label}.originWorker must appear in discoveredBy"
1042
+ )
1043
+ source_items = _parse_source_items(
1044
+ group.get("sourceItems"), label, analysis_workers
1045
+ )
1046
+ source_workers = [
1047
+ worker_id
1048
+ for worker_id in analysis_workers
1049
+ if worker_id in discovered_by
1050
+ or any(item["worker"] == worker_id for item in source_items)
1051
+ ]
1052
+ return {
1053
+ "findingId": _required_string(group, "findingId", label),
1054
+ "summary": _required_string(group, "summary", label),
1055
+ "category": _required_string(group, "category", label),
1056
+ "ticketIds": _string_array(group.get("ticketIds"), f"{label}.ticketIds"),
1057
+ "originWorker": origin,
1058
+ "originEvidence": origin_evidence,
1059
+ "discoveredBy": discovered_by,
1060
+ "sourceItems": source_items,
1061
+ "classification": None,
1062
+ "rounds": [],
1063
+ "consensusWorkers": source_workers,
1064
+ "dissentingWorkers": [],
1065
+ }, source_workers
1066
+
1067
+
1068
+ def _parse_discovered_by(
1069
+ value: Any,
1070
+ label: str,
1071
+ analysis_workers: list[str],
1072
+ ) -> dict[str, dict[str, str]]:
1073
+ raw = _object(value, f"{label}.discoveredBy")
1074
+ parsed: dict[str, dict[str, str]] = {}
1075
+ for worker_id, details_value in raw.items():
1076
+ if worker_id not in analysis_workers:
1077
+ raise ConvergenceContractError(
1078
+ f"{label}.discoveredBy contains non-analysis worker: {worker_id}"
1079
+ )
1080
+ details = _object(details_value, f"{label}.discoveredBy.{worker_id}")
1081
+ parsed[worker_id] = {
1082
+ "itemId": _required_string(
1083
+ details, "itemId", f"{label}.discoveredBy.{worker_id}"
1084
+ ),
1085
+ "evidence": _required_string(
1086
+ details, "evidence", f"{label}.discoveredBy.{worker_id}"
1087
+ ),
1088
+ }
1089
+ if not parsed:
1090
+ raise ConvergenceContractError(f"{label}.discoveredBy must not be empty")
1091
+ return parsed
1092
+
1093
+
1094
+ def _parse_source_items(
1095
+ value: Any,
1096
+ label: str,
1097
+ analysis_workers: list[str],
1098
+ ) -> list[dict[str, str]]:
1099
+ if not isinstance(value, list) or not value:
1100
+ raise ConvergenceContractError(f"{label}.sourceItems must be a non-empty array")
1101
+ items: list[dict[str, str]] = []
1102
+ for index, raw in enumerate(value):
1103
+ item = _object(raw, f"{label}.sourceItems[{index}]")
1104
+ worker = _required_string(item, "worker", f"{label}.sourceItems[{index}]")
1105
+ if worker not in analysis_workers:
1106
+ raise ConvergenceContractError(
1107
+ f"{label}.sourceItems contains non-analysis worker {worker}; "
1108
+ "report-writer never votes"
1109
+ )
1110
+ items.append(
1111
+ {
1112
+ "worker": worker,
1113
+ "itemId": _required_string(
1114
+ item, "itemId", f"{label}.sourceItems[{index}]"
1115
+ ),
1116
+ }
1117
+ )
1118
+ return items
1119
+
1120
+
1121
+ def _round_limit(value: Any, name: str) -> int:
1122
+ if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 3:
1123
+ raise ConvergenceContractError(f"config.{name} must be an integer from 1 to 3")
1124
+ return value
1125
+
1126
+
1127
+ def _string_array(value: Any, label: str) -> list[str]:
1128
+ if not isinstance(value, list) or not value:
1129
+ raise ConvergenceContractError(f"{label} must be a non-empty string array")
1130
+ result = []
1131
+ for item in value:
1132
+ if not isinstance(item, str) or not item.strip():
1133
+ raise ConvergenceContractError(f"{label} must contain non-empty strings")
1134
+ result.append(item.strip())
1135
+ return result
1136
+
1137
+
1138
+ def _object(value: Any, label: str) -> Mapping[str, Any]:
1139
+ if not isinstance(value, Mapping):
1140
+ raise ConvergenceContractError(f"{label} must be an object")
1141
+ return value
1142
+
1143
+
1144
+ def _required_string(
1145
+ payload: Mapping[str, Any],
1146
+ key: str,
1147
+ label: str,
1148
+ ) -> str:
1149
+ value = payload.get(key)
1150
+ if not isinstance(value, str) or not value.strip():
1151
+ raise ConvergenceContractError(f"{label}.{key} must be a non-empty string")
1152
+ return value.strip()