@dzhechkov/p-replicator 1.5.17 → 1.5.18

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 (21) hide show
  1. package/.dz-manifest.json +51 -23
  2. package/README.md +5 -3
  3. package/package.json +1 -1
  4. package/sbom.json +106 -36
  5. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  6. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  7. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  8. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  9. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  10. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  11. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
  12. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  13. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  14. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  15. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
  16. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  17. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  18. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  19. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  20. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  21. package/tests/snapshot/baseline.json +24 -9
@@ -0,0 +1,133 @@
1
+ {
2
+ "_provenance": {
3
+ "status": "CLAIMED, not independently re-derived",
4
+ "evidence_class": "ASSERTED",
5
+ "note": "The four field cases are taken verbatim from a field report; this pipeline did not open the sources. Their `verbatim` strings are paraphrases of what the field report said the sources said, NOT transcriptions checked against a paper, and no DOI/NCT identifier is invented to make them look more grounded than they are. These fixtures assert the MATCHER's behaviour, which does not depend on the citation being real.",
6
+ "patient": "male, BMI 25, triglycerides 236 mg/dL, cardiovascular risk moderate (the brief's patient)"
7
+ },
8
+ "patient_profile": {
9
+ "sex": "male",
10
+ "bmi": 25,
11
+ "triglycerides_mg_dl": 236,
12
+ "cv_risk": "moderate"
13
+ },
14
+ "cases": [
15
+ {
16
+ "id": "testosterone-weight-loss",
17
+ "kind": "real-field-reversal",
18
+ "claim": "Weight loss raises testosterone",
19
+ "expected_verdict": "partial",
20
+ "expected_discrepancies": [
21
+ {"field": "bmi", "kind": "baseline-out-of-range", "direction": "below"}
22
+ ],
23
+ "why_not_none": "The patient could have enrolled; the effect was simply not measured from where his BMI stands. Saying 'none' here would be the mirror error of saying 'full'.",
24
+ "study_population": {
25
+ "description": "men with obesity (BMI >= 30) enrolled in a weight-loss intervention",
26
+ "locator": "[Methods, Participants]",
27
+ "criteria": {
28
+ "sex": {"op": "==", "value": "male", "kind": "eligibility",
29
+ "verbatim": "men", "locator": "[Methods, Participants]"},
30
+ "bmi_min": {"op": ">=", "value": 30, "kind": "baseline",
31
+ "verbatim": "men with obesity (BMI >= 30 kg/m2)",
32
+ "locator": "[Methods, Participants]"}
33
+ }
34
+ }
35
+ },
36
+ {
37
+ "id": "ed-rct-bmi30",
38
+ "kind": "real-field-reversal",
39
+ "claim": "Erectile dysfunction is reversible (randomised controlled trial)",
40
+ "expected_verdict": "none",
41
+ "expected_discrepancies": [
42
+ {"field": "bmi", "kind": "eligibility-excluded", "direction": "below"}
43
+ ],
44
+ "why_none": "BMI >= 30 was an ENROLMENT criterion — this patient would not have been in the trial at all.",
45
+ "study_population": {
46
+ "description": "participants with BMI >= 30 randomised to the intervention",
47
+ "locator": "[Methods, Eligibility]",
48
+ "criteria": {
49
+ "bmi_min": {"op": ">=", "value": 30, "kind": "eligibility",
50
+ "verbatim": "inclusion criteria: BMI >= 30 kg/m2",
51
+ "locator": "[Methods, Eligibility]"}
52
+ }
53
+ }
54
+ },
55
+ {
56
+ "id": "traverse-cv-safety",
57
+ "kind": "real-field-reversal",
58
+ "claim": "TRAVERSE found no excess cardiovascular harm from testosterone therapy",
59
+ "expected_verdict": "none",
60
+ "expected_discrepancies": [
61
+ {"field": "cv_risk", "kind": "eligibility-excluded", "direction": "outside-set"}
62
+ ],
63
+ "why_none": "A null-harm finding established in high-cardiovascular-risk men does not extend by default to a man who is not high-risk.",
64
+ "study_population": {
65
+ "description": "men at high cardiovascular risk with hypogonadism",
66
+ "locator": "[Methods, Participants]",
67
+ "criteria": {
68
+ "cv_risk": {"op": "==", "value": "high", "kind": "eligibility",
69
+ "verbatim": "men with high cardiovascular risk",
70
+ "locator": "[Methods, Participants]"}
71
+ }
72
+ }
73
+ },
74
+ {
75
+ "id": "omega3-ldl",
76
+ "kind": "real-field-reversal",
77
+ "claim": "Omega-3 raises LDL by 44.5%",
78
+ "expected_verdict": "partial",
79
+ "expected_discrepancies": [
80
+ {"field": "triglycerides", "kind": "baseline-out-of-range", "direction": "below"}
81
+ ],
82
+ "why_not_none": "Severe hypertriglyceridemia is a different metabolic regime; the magnitude is not established at 236 mg/dL, but the patient is not excluded from the population.",
83
+ "study_population": {
84
+ "description": "patients with severe hypertriglyceridemia (baseline triglycerides >= 800 mg/dL)",
85
+ "locator": "[Methods, Baseline characteristics]",
86
+ "criteria": {
87
+ "triglycerides_mg_dl_min": {"op": ">=", "value": 800, "kind": "baseline",
88
+ "verbatim": "baseline triglycerides >= 800 mg/dL",
89
+ "locator": "[Methods, Baseline characteristics]"}
90
+ }
91
+ }
92
+ },
93
+ {
94
+ "id": "synthetic-missing-patient-value",
95
+ "kind": "synthetic",
96
+ "claim": "Synthetic: the patient profile does not carry the axis the study used",
97
+ "expected_verdict": "unknown",
98
+ "expected_discrepancies": [
99
+ {"field": "bmi", "kind": "patient-value-missing", "direction": "absent"}
100
+ ],
101
+ "patient_profile_override": {"sex": "male"},
102
+ "note": "01_requirements.md AC-5 called this `partial` under the three-verdict wording. ADR-001 §2 and AM-6 confirmed a FOURTH verdict: an unestablished criterion is not a milder kind of match, so this is `unknown`. The distinction is the whole of D-17's discipline — unevaluable is never a pass.",
103
+ "study_population": {
104
+ "description": "men with obesity (BMI >= 30)",
105
+ "locator": "[Methods]",
106
+ "criteria": {
107
+ "sex": {"op": "==", "value": "male", "kind": "eligibility",
108
+ "verbatim": "men", "locator": "[Methods]"},
109
+ "bmi_min": {"op": ">=", "value": 30, "kind": "eligibility",
110
+ "verbatim": "BMI >= 30 kg/m2", "locator": "[Methods]"}
111
+ }
112
+ }
113
+ },
114
+ {
115
+ "id": "synthetic-full",
116
+ "kind": "synthetic",
117
+ "claim": "Synthetic: every stated criterion is satisfied by a known patient value",
118
+ "expected_verdict": "full",
119
+ "expected_discrepancies": [],
120
+ "patient_profile_override": {"sex": "male", "bmi": 33},
121
+ "study_population": {
122
+ "description": "men with obesity (BMI >= 30)",
123
+ "locator": "[Methods]",
124
+ "criteria": {
125
+ "sex": {"op": "==", "value": "male", "kind": "eligibility",
126
+ "verbatim": "men", "locator": "[Methods]"},
127
+ "bmi_min": {"op": ">=", "value": 30, "kind": "eligibility",
128
+ "verbatim": "BMI >= 30 kg/m2", "locator": "[Methods]"}
129
+ }
130
+ }
131
+ }
132
+ ]
133
+ }
@@ -16,8 +16,9 @@ Features:
16
16
  import heapq
17
17
  import json
18
18
  import hashlib
19
+ import time
19
20
  from dataclasses import dataclass, field, asdict
20
- from typing import Dict, Set, List, Optional, Tuple, Any
21
+ from typing import Dict, FrozenSet, Set, List, Optional, Tuple, Any, Union
21
22
  from datetime import datetime
22
23
  from enum import Enum
23
24
 
@@ -98,15 +99,70 @@ class ResearchPlan:
98
99
  }
99
100
 
100
101
 
102
+ class PlanVerdict(Enum):
103
+ """
104
+ Verdict for a planning attempt that did not produce a plan.
105
+
106
+ Two fundamentally different situations that must never share one message
107
+ (precedented in this repo: INSUFFICIENT_DATA in harness-core/src/compounding.ts
108
+ and 'inconclusive' in harness-core/src/skills-verify.ts — "don't know" is a
109
+ first-class verdict, not a failure collapsed into the same bucket as
110
+ "definitely no"):
111
+
112
+ - GOAL_UNREACHABLE: proven — no sequence of available actions can ever
113
+ satisfy the goal from the initial state, regardless of budget.
114
+ - SEARCH_EXHAUSTED: not proven unreachable — the search simply did not find
115
+ a plan within its iteration budget. Retrying with a higher
116
+ ``max_iterations`` may succeed.
117
+ """
118
+ GOAL_UNREACHABLE = "goal_unreachable"
119
+ SEARCH_EXHAUSTED = "search_exhausted"
120
+
121
+
122
+ @dataclass
123
+ class PlanNotFound:
124
+ """
125
+ First-class "no plan" result carrying WHY no plan was returned.
126
+
127
+ Callers must branch on ``isinstance(result, ResearchPlan)`` /
128
+ ``isinstance(result, PlanNotFound)`` rather than truthiness. As defense in
129
+ depth, ``__bool__`` returns False, so legacy TRUTHINESS checks
130
+ (``if plan:`` / ``if not plan:``) keep behaving exactly as they did when
131
+ this function returned ``None``.
132
+
133
+ IDENTITY checks are NOT covered by that safety net: ``plan is None`` is
134
+ now ALWAYS False. A caller migrating from the old contract via
135
+ ``if plan is not None:`` will read a no-plan result as success and then
136
+ raise ``AttributeError`` on ``.actions`` — or, worse, silently proceed.
137
+ Migrate every ``is None`` / ``is not None`` check to
138
+ ``isinstance(result, ResearchPlan)``.
139
+ """
140
+ verdict: PlanVerdict
141
+ iterations: Optional[int] = None # set for SEARCH_EXHAUSTED; None for GOAL_UNREACHABLE
142
+ message: str = "" # actionable text; carries retry guidance for SEARCH_EXHAUSTED
143
+
144
+ def __bool__(self) -> bool:
145
+ return False
146
+
147
+
101
148
  # Extended research actions library with Ed25519 verification support
102
149
  RESEARCH_ACTIONS = [
103
150
  # Setup Actions
151
+ #
152
+ # NOTE on the two capability flags below (historical defect: `whitelist_available`):
153
+ # `issuer_keys_available` (real Ed25519 issuer key material genuinely held) and
154
+ # `source_class_verified` (this class of source is capable of signed delivery at all)
155
+ # are INITIAL-STATE-ONLY facts about the deployment. No action in this library may
156
+ # ever list either of them in `effects` — an action that could manufacture its own
157
+ # key/source-class credibility for free would repeat the `whitelist_available`
158
+ # defect, where a dead flag plus a zero-precondition `configure_trusted_issuers`
159
+ # let the planner fabricate "verified" plans backed by nothing.
104
160
  ResearchAction(
105
161
  name="configure_trusted_issuers",
106
- preconditions=set(),
162
+ preconditions={"issuer_keys_available"},
107
163
  effects={"whitelist_active", "verification_ready"},
108
164
  cost=0,
109
- description="Initialize trusted issuer whitelist"
165
+ description="Initialize trusted issuer whitelist (requires real issuer key material)"
110
166
  ),
111
167
  ResearchAction(
112
168
  name="generate_research_keypair",
@@ -126,7 +182,7 @@ RESEARCH_ACTIONS = [
126
182
  ),
127
183
  ResearchAction(
128
184
  name="web_search_verified",
129
- preconditions={"topic_defined", "whitelist_active"},
185
+ preconditions={"topic_defined", "whitelist_active", "source_class_verified"},
130
186
  effects={"verified_candidates_found", "trusted_sources_identified"},
131
187
  cost=2,
132
188
  description="Search with priority to trusted issuer sources",
@@ -158,7 +214,7 @@ RESEARCH_ACTIONS = [
158
214
  ),
159
215
  ResearchAction(
160
216
  name="fetch_signed_source",
161
- preconditions={"sources_identified", "whitelist_active"},
217
+ preconditions={"sources_identified", "whitelist_active", "source_class_verified"},
162
218
  effects={"signed_content_retrieved", "signature_verified"},
163
219
  cost=3,
164
220
  description="Retrieve content with Ed25519 signature verification",
@@ -328,29 +384,122 @@ def heuristic(state: Set[str], goal: Set[str], unsigned_claims: int = 0) -> floa
328
384
  return base_cost + verification_penalty
329
385
 
330
386
 
387
+ # Adaptive default iteration budgets, calibrated by MEASUREMENT (feature
388
+ # ha-slice-e-goap-blockers, 2026-08-04): binary search for the minimal
389
+ # max_iterations that finds a plan for each shipped demo scenario showed the
390
+ # dominant variable is verification_mode, not goal-set size — STRICT scenarios
391
+ # need ~20-100x the iterations of MODERATE ones at comparable goal counts,
392
+ # because the strict-mode unsigned-claims rejection narrows the acceptable goal
393
+ # nodes drastically. Measured minima: competitive/MODERATE 3-goal = 2,249;
394
+ # high_stakes/STRICT 5-goal = 55,128. Floors carry ~2x margin over the worst
395
+ # measured scenario of their mode class. PARANOID inherits the STRICT floor:
396
+ # its search behavior is identical to STRICT in this planner (the only
397
+ # search-relevant switch is require_verification, shared by both modes; the
398
+ # 0.99-vs-0.95 threshold affects reporting, not the search), and the floor is
399
+ # exercised by a shipped scenario (test_default_budget_finds_high_stakes_
400
+ # paranoid_plan runs high_stakes/PARANOID to a plan at the default budget) —
401
+ # so "PARANOID = STRICT floor" is a tested identity, not a bare extrapolation.
402
+ # DEVELOPMENT shares the MODERATE floor via the else-branch (also tested).
403
+ _MODERATE_ITERATIONS_FLOOR = 5_000 # ~2.2x margin over measured competitive/MODERATE minimum (2,249)
404
+ _STRICT_ITERATIONS_FLOOR = 100_000 # ~1.8x margin over measured high_stakes/STRICT minimum (55,128)
405
+ _ITERATIONS_PER_GOAL_MODERATE = 50 # secondary headroom term for large goal sets
406
+ _ITERATIONS_PER_GOAL_STRICT = 500
407
+
408
+
409
+ def _default_max_iterations(goal_state: Set[str], verification_mode: VerificationMode) -> int:
410
+ """Adaptive default search budget (see calibration note above)."""
411
+ if verification_mode in (VerificationMode.STRICT, VerificationMode.PARANOID):
412
+ return max(_STRICT_ITERATIONS_FLOOR, _ITERATIONS_PER_GOAL_STRICT * len(goal_state))
413
+ return max(_MODERATE_ITERATIONS_FLOOR, _ITERATIONS_PER_GOAL_MODERATE * len(goal_state))
414
+
415
+
416
+ def _reachability_closure(initial_state: Set[str], actions: List[ResearchAction]) -> FrozenSet[str]:
417
+ """
418
+ Delete-free forward-chaining reachability closure (classic STRIPS
419
+ relaxed-planning-graph technique).
420
+
421
+ Because ``ResearchAction.apply()`` is a pure set-union (state only grows,
422
+ facts are never removed), forward-chaining preconditions -> effects to a
423
+ fixed point is bounded by ``len(actions)`` passes: each action can flip
424
+ from inapplicable to applicable at most once. Cost: O(len(actions)^2) set
425
+ operations — microseconds, and by construction independent of any
426
+ iteration budget.
427
+
428
+ SOUNDNESS NOTE: this closure is a *relaxed* check — it ignores the
429
+ unsigned-claims / verification-mode gate entirely. ``goal not <= closure``
430
+ is therefore a SOUND proof of unreachability (the closure is a superset of
431
+ everything the constrained search could ever reach). ``goal <= closure``
432
+ is NOT a guarantee the constrained A* search finds a plan within any given
433
+ budget — which is exactly why SEARCH_EXHAUSTED remains meaningful even
434
+ when the closure says "reachable in principle".
435
+ """
436
+ state = set(initial_state)
437
+ changed = True
438
+ while changed:
439
+ changed = False
440
+ for action in actions:
441
+ if action.preconditions.issubset(state):
442
+ before = len(state)
443
+ state |= action.effects
444
+ if len(state) > before:
445
+ changed = True
446
+ return frozenset(state)
447
+
448
+
331
449
  def find_research_plan(
332
450
  initial_state: Set[str],
333
451
  goal_state: Set[str],
334
452
  actions: List[ResearchAction] = None,
335
453
  verification_mode: VerificationMode = VerificationMode.MODERATE,
336
- max_iterations: int = 1000
337
- ) -> Optional[ResearchPlan]:
454
+ max_iterations: Optional[int] = None,
455
+ max_seconds: Optional[float] = None
456
+ ) -> Union[ResearchPlan, "PlanNotFound"]:
338
457
  """
339
458
  A* search to find optimal research plan with verification support.
340
-
459
+
341
460
  Args:
342
461
  initial_state: Starting conditions
343
462
  goal_state: Target conditions to achieve
344
463
  actions: Available actions (defaults to RESEARCH_ACTIONS)
345
464
  verification_mode: How strict to be about verification
346
- max_iterations: Maximum search iterations
347
-
465
+ max_iterations: Maximum search iterations. ``None`` (the default)
466
+ selects an adaptive budget calibrated per verification mode and
467
+ goal-set size (see ``_default_max_iterations``); an explicit value
468
+ is respected unchanged.
469
+ max_seconds: Optional wall-clock ceiling for the search, independent
470
+ of the iteration count. ``None`` (the default) keeps the search
471
+ deterministic (bounded only by ``max_iterations``) — note that on
472
+ the FAILURE path this means the full iteration budget is burned
473
+ (measured: ~7-10s at the default STRICT budget of 100,000 for a
474
+ goal the verification gate never accepts). Latency-sensitive
475
+ callers should pass a ceiling (e.g. ``max_seconds=2.0``); hitting
476
+ it returns SEARCH_EXHAUSTED with the ceiling named in the message.
477
+
348
478
  Returns:
349
- ResearchPlan object or None if no plan found
479
+ ResearchPlan on success, otherwise a PlanNotFound whose ``verdict``
480
+ distinguishes:
481
+ - PlanVerdict.GOAL_UNREACHABLE — proven: no action sequence can ever
482
+ satisfy the goal (decided promptly by a reachability closure, or by
483
+ exhaustive exploration of the state space).
484
+ - PlanVerdict.SEARCH_EXHAUSTED — the iteration budget (or the
485
+ ``max_seconds`` wall-clock ceiling) ran out before a plan was found;
486
+ ``iterations`` carries the iterations spent. This verdict is an
487
+ honest "don't know": the relaxed closure passing means the goal is
488
+ not PROVABLY unreachable, but it is NOT a guarantee that any budget
489
+ will find a plan — the closure ignores the verification-mode gate,
490
+ which can permanently reject every path (see the
491
+ ``_reachability_closure`` soundness note).
492
+ Branch on ``isinstance(result, ResearchPlan)`` — never on truthiness
493
+ alone (PlanNotFound is falsy only as a legacy safety net), and NEVER
494
+ on ``result is None`` (this function no longer returns ``None``; an
495
+ identity check reads a no-plan result as success).
350
496
  """
351
497
  if actions is None:
352
498
  actions = RESEARCH_ACTIONS
353
-
499
+
500
+ if max_iterations is None:
501
+ max_iterations = _default_max_iterations(goal_state, verification_mode)
502
+
354
503
  # Check if goal already satisfied
355
504
  if goal_state.issubset(initial_state):
356
505
  return ResearchPlan(
@@ -361,7 +510,24 @@ def find_research_plan(
361
510
  estimated_confidence=1.0,
362
511
  unsigned_claims_count=0
363
512
  )
364
-
513
+
514
+ # Prompt unreachability proof, independent of max_iterations: if the goal
515
+ # is not inside the delete-free closure, no budget can ever reach it.
516
+ closure = _reachability_closure(initial_state, actions)
517
+ if not goal_state.issubset(closure):
518
+ missing = sorted(goal_state - closure)
519
+ return PlanNotFound(
520
+ verdict=PlanVerdict.GOAL_UNREACHABLE,
521
+ message=(
522
+ "Goal is provably unreachable from the initial state: no sequence of "
523
+ f"available actions can ever produce {missing}. "
524
+ "Raising max_iterations cannot help. If the missing facts are "
525
+ "deployment capabilities (e.g. issuer_keys_available, "
526
+ "source_class_verified), they must be supplied in the initial state "
527
+ "by a real integration — the planner cannot manufacture them."
528
+ )
529
+ )
530
+
365
531
  # Priority queue
366
532
  start_h = heuristic(initial_state, goal_state)
367
533
  open_set = [PlanNode(start_h, 0, frozenset(initial_state), [], 0)]
@@ -372,8 +538,17 @@ def find_research_plan(
372
538
  # Verification settings
373
539
  require_verification = verification_mode in [VerificationMode.STRICT, VerificationMode.PARANOID]
374
540
 
541
+ # Optional wall-clock ceiling (GAP-6): independent of the iteration count,
542
+ # so a latency-sensitive caller is not forced to burn the full (large)
543
+ # adaptive budget on a goal the verification gate never accepts.
544
+ deadline = None if max_seconds is None else time.perf_counter() + max_seconds
545
+ time_ceiling_hit = False
546
+
375
547
  iterations = 0
376
548
  while open_set and iterations < max_iterations:
549
+ if deadline is not None and time.perf_counter() >= deadline:
550
+ time_ceiling_hit = True
551
+ break
377
552
  iterations += 1
378
553
 
379
554
  current = heapq.heappop(open_set)
@@ -441,8 +616,54 @@ def find_research_plan(
441
616
  unsigned_claims=new_unsigned
442
617
  )
443
618
  heapq.heappush(open_set, new_node)
444
-
445
- return None # No plan found
619
+
620
+ # No plan found — distinguish WHY (never conflate the two exits):
621
+ if open_set:
622
+ # Budget (iterations or wall clock) hit with work remaining: an honest
623
+ # "don't know yet". The relaxed closure passing means the goal is NOT
624
+ # PROVABLY unreachable — it does NOT mean a larger budget will find a
625
+ # plan: the closure ignores the verification-mode unsigned-claims
626
+ # gate, which (especially in strict/paranoid mode) can permanently
627
+ # reject every path to the goal (see _reachability_closure's
628
+ # soundness note; this message must never over-claim past it).
629
+ if time_ceiling_hit:
630
+ stopped = (
631
+ f"Search stopped by the wall-clock ceiling (max_seconds={max_seconds}) "
632
+ f"after {iterations} iterations without finding a plan."
633
+ )
634
+ else:
635
+ stopped = (
636
+ f"Search exhausted after {iterations} iterations without finding a plan."
637
+ )
638
+ return PlanNotFound(
639
+ verdict=PlanVerdict.SEARCH_EXHAUSTED,
640
+ iterations=iterations,
641
+ message=(
642
+ stopped + " The relaxed reachability closure passed, so the goal is "
643
+ "not provably unreachable — but that is NOT a guarantee that a larger "
644
+ "budget will find a plan: the closure ignores the verification-mode "
645
+ "unsigned-claims gate, which can permanently reject every path to the "
646
+ "goal (especially in strict/paranoid mode). A retry with a higher "
647
+ f"max_iterations (e.g. max_iterations={max(iterations, 1) * 10}) MAY "
648
+ "succeed; if repeated increases keep exhausting, treat the goal as "
649
+ "unreachable under the active verification constraints instead of "
650
+ "raising the budget further."
651
+ )
652
+ )
653
+ # open_set genuinely emptied: the search exhaustively explored every
654
+ # reachable state without accepting a goal node — a second, stronger,
655
+ # exhaustive proof of unreachability under the active verification
656
+ # constraints (the relaxed closure ignores those constraints, so both
657
+ # outcomes are consistent).
658
+ return PlanNotFound(
659
+ verdict=PlanVerdict.GOAL_UNREACHABLE,
660
+ message=(
661
+ "Search space exhaustively explored (open set emptied) without a plan "
662
+ "satisfying the active verification constraints — the goal is "
663
+ "unreachable under the current verification mode; raising "
664
+ "max_iterations cannot help."
665
+ )
666
+ )
446
667
 
447
668
 
448
669
  def format_plan(plan: ResearchPlan, action_library: List[ResearchAction] = None) -> str:
@@ -607,14 +828,24 @@ class GOAPResearchPlanner:
607
828
  def __init__(
608
829
  self,
609
830
  verification_mode: str = "moderate",
610
- trusted_issuers: Optional[List[str]] = None
831
+ trusted_issuers: Optional[List[str]] = None,
832
+ issuer_keys_available: bool = False,
833
+ source_class_verified: bool = False
611
834
  ):
612
835
  """
613
836
  Initialize planner.
614
-
837
+
615
838
  Args:
616
839
  verification_mode: 'development', 'moderate', 'strict', or 'paranoid'
617
- trusted_issuers: List of trusted issuer domains
840
+ trusted_issuers: List of trusted issuer domains (informational — a
841
+ list of domain strings is NOT evidence of key possession)
842
+ issuer_keys_available: True only when real Ed25519 key material for
843
+ the configured issuers genuinely exists. Default False — the
844
+ package's honest default, since no real issuer keys exist for
845
+ PubMed/PMC/DOI/WHO.
846
+ source_class_verified: True only for source classes actually
847
+ capable of Ed25519-signed delivery. Default False for the same
848
+ reason.
618
849
  """
619
850
  mode_map = {
620
851
  "development": VerificationMode.DEVELOPMENT,
@@ -624,38 +855,56 @@ class GOAPResearchPlanner:
624
855
  }
625
856
  self.verification_mode = mode_map.get(verification_mode.lower(), VerificationMode.MODERATE)
626
857
  self.trusted_issuers = trusted_issuers or []
858
+ self.issuer_keys_available = issuer_keys_available
859
+ self.source_class_verified = source_class_verified
627
860
  self.verification_ledger: List[VerificationResult] = []
628
861
 
629
862
  def plan(
630
863
  self,
631
864
  goal_type: str,
632
865
  topic: str,
633
- custom_goals: Optional[Set[str]] = None
634
- ) -> Optional[ResearchPlan]:
866
+ custom_goals: Optional[Set[str]] = None,
867
+ max_seconds: Optional[float] = None
868
+ ) -> Union[ResearchPlan, PlanNotFound]:
635
869
  """
636
870
  Generate research plan for given goal type.
637
-
871
+
638
872
  Args:
639
873
  goal_type: Type of research (see create_research_goal)
640
874
  topic: Research topic (for logging)
641
875
  custom_goals: Override default goals
642
-
876
+ max_seconds: Optional wall-clock ceiling for the search (see
877
+ find_research_plan) — bounds failure-path latency, which
878
+ otherwise burns the full adaptive iteration budget.
879
+
643
880
  Returns:
644
- ResearchPlan or None
881
+ ResearchPlan on success, or PlanNotFound with a verdict
882
+ (PlanVerdict.GOAL_UNREACHABLE — proven impossible; or
883
+ PlanVerdict.SEARCH_EXHAUSTED — budget ran out; a retry with a
884
+ higher max_iterations MAY succeed, but is not guaranteed to — the
885
+ verification gate can permanently reject every path). Branch on
886
+ ``isinstance(result, ResearchPlan)`` — never on ``result is None``
887
+ (this method no longer returns ``None``; an identity check reads a
888
+ no-plan result as success).
645
889
  """
646
890
  initial, goal = create_research_goal(goal_type, self.verification_mode)
647
-
891
+
648
892
  if custom_goals:
649
893
  goal = custom_goals
650
-
651
- # Add verification-related initial state if configured
652
- if self.trusted_issuers:
653
- initial.add("whitelist_available")
654
-
894
+
895
+ # Deployment capability facts are initial-state-only: no action can
896
+ # produce them (see the RESEARCH_ACTIONS setup note). Each is added
897
+ # only when the deployment genuinely provides it.
898
+ if self.issuer_keys_available:
899
+ initial.add("issuer_keys_available")
900
+ if self.source_class_verified:
901
+ initial.add("source_class_verified")
902
+
655
903
  return find_research_plan(
656
904
  initial_state=initial,
657
905
  goal_state=goal,
658
- verification_mode=self.verification_mode
906
+ verification_mode=self.verification_mode,
907
+ max_seconds=max_seconds
659
908
  )
660
909
 
661
910
  def format_plan(self, plan: ResearchPlan) -> str:
@@ -679,42 +928,63 @@ if __name__ == "__main__":
679
928
  initial, goal = create_research_goal("exploratory", VerificationMode.MODERATE)
680
929
  print(f"Initial State: {initial}")
681
930
  print(f"Goal State: {goal}")
682
-
931
+
683
932
  plan = find_research_plan(initial, goal, verification_mode=VerificationMode.MODERATE)
684
- if plan:
933
+ if isinstance(plan, ResearchPlan):
685
934
  print(format_plan(plan))
686
935
  else:
687
- print("No plan found!")
688
-
936
+ print(f"No plan: {plan.verdict.value} — {plan.message}")
937
+
689
938
  # Example 2: High-stakes verified research
690
939
  print("\n\n[2] HIGH-STAKES VERIFIED RESEARCH (Strict Mode)")
691
940
  print("-" * 50)
692
-
941
+
693
942
  initial, goal = create_research_goal("high_stakes", VerificationMode.STRICT)
694
943
  print(f"Initial State: {initial}")
695
944
  print(f"Goal State: {goal}")
696
-
945
+
946
+ # Honest default: no real Ed25519 issuer keys exist for the package's
947
+ # actual health sources (PubMed/PMC/DOI/WHO), so high_stakes is provably
948
+ # unreachable — reported as a first-class verdict, not a fabricated plan.
949
+ print("\n[2a] Honest default (no issuer keys):")
697
950
  plan = find_research_plan(initial, goal, verification_mode=VerificationMode.STRICT)
698
- if plan:
951
+ if isinstance(plan, ResearchPlan):
699
952
  print(format_plan(plan))
700
953
  else:
701
- print("No plan found!")
702
-
954
+ print(f"No plan: {plan.verdict.value} — {plan.message}")
955
+
956
+ # With a real signed-source integration (issuer key material genuinely
957
+ # held AND a source class capable of signed delivery), the same goal is
958
+ # reachable at the DEFAULT adaptive budget:
959
+ print("\n[2b] With real signed-source integration (issuer_keys_available + source_class_verified):")
960
+ initial_with_keys = initial | {"issuer_keys_available", "source_class_verified"}
961
+ plan = find_research_plan(initial_with_keys, goal, verification_mode=VerificationMode.STRICT)
962
+ if isinstance(plan, ResearchPlan):
963
+ print(format_plan(plan))
964
+ else:
965
+ print(f"No plan: {plan.verdict.value} — {plan.message}")
966
+
703
967
  # Example 3: Using the high-level planner interface
704
968
  print("\n\n[3] HIGH-LEVEL PLANNER INTERFACE")
705
969
  print("-" * 50)
706
-
970
+
707
971
  planner = GOAPResearchPlanner(
708
972
  verification_mode="strict",
709
- trusted_issuers=["reuters.com", "nature.com", "arxiv.org"]
973
+ trusted_issuers=["reuters.com", "nature.com", "arxiv.org"],
974
+ # Test-double capability flags simulating a real signed-source
975
+ # integration; the honest default for both is False.
976
+ issuer_keys_available=True,
977
+ source_class_verified=True
710
978
  )
711
-
979
+
712
980
  plan = planner.plan(
713
981
  goal_type="verified_exploratory",
714
982
  topic="AI safety regulations 2025"
715
983
  )
716
-
717
- if plan:
984
+
985
+ if isinstance(plan, ResearchPlan):
718
986
  print(planner.format_plan(plan))
719
987
  print("\nJSON Export:")
720
988
  print(planner.export_plan(plan))
989
+ else:
990
+ print(f"No plan: {plan.verdict.value} — {plan.message}")