@dzhechkov/p-replicator 1.5.17 → 1.6.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 (85) hide show
  1. package/.dz-manifest.json +173 -65
  2. package/CHANGELOG.md +135 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +2 -2
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/05_architecture.md +6 -2
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +2 -2
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/05_architecture.md +1 -1
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +41 -18
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +347 -77
  19. package/src/utils.js +2 -0
  20. package/templates/.claude/agents/doc-validator.md +2 -1
  21. package/templates/.claude/agents/product-discoverer.md +1 -1
  22. package/templates/.claude/commands/next.md +16 -0
  23. package/templates/.claude/commands/replicate.md +126 -8
  24. package/templates/.claude/commands/start.md +19 -1
  25. package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
  26. package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
  27. package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
  28. package/templates/.claude/hooks/check-ports.cjs +232 -0
  29. package/templates/.claude/hooks/session-insights.cjs +13 -1
  30. package/templates/.claude/hooks/state-update.cjs +13 -1
  31. package/templates/.claude/hooks/statusline.cjs +145 -18
  32. package/templates/.claude/rules/docker-ports.md +123 -0
  33. package/templates/.claude/rules/replicate-pipeline.md +5 -2
  34. package/templates/.claude/settings.json +5 -5
  35. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  36. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  37. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  44. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  45. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  46. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  47. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  48. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  49. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  50. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
  51. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  52. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  53. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  54. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
  55. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  56. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  57. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  58. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  59. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  60. package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
  61. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  62. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
  63. package/tests/e2e/lifecycle.test.js +21 -10
  64. package/tests/snapshot/baseline.json +51 -34
  65. package/tests/snapshot/update-baseline.js +2 -1
  66. package/tests/unit/adr-decision-coverage.test.js +137 -0
  67. package/tests/unit/adr-scanner-contract.test.js +108 -0
  68. package/tests/unit/autocommit-deletion.test.js +242 -0
  69. package/tests/unit/check-ports.test.js +184 -0
  70. package/tests/unit/db-port-rule.test.js +216 -0
  71. package/tests/unit/detect-parse-anchor.test.js +109 -0
  72. package/tests/unit/external-dependency-check.test.js +209 -0
  73. package/tests/unit/growth-module-b2b-gate.test.js +104 -0
  74. package/tests/unit/hooks-project-anchored.test.js +223 -0
  75. package/tests/unit/hooks-report-failures.test.js +207 -0
  76. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  77. package/tests/unit/roadmap-one-schema.test.js +179 -0
  78. package/tests/unit/sparc-reconciliation.test.js +117 -0
  79. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  80. package/tests/unit/statusline-honest-labels.test.js +178 -0
  81. package/tests/unit/statusline-two-roots.test.js +237 -0
  82. package/tests/unit/sync-templates-guard.test.js +209 -0
  83. package/tests/unit/utils.test.js +2 -2
  84. package/tests/unit/validation-gate-teeth.test.js +158 -0
  85. package/LICENSE +0 -21
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for the GOAP planner's three fixed blockers (feature ha-slice-e-goap-blockers).
4
+
5
+ Blocker 1 — adaptive iteration budget: the package's own shipped high_stakes/STRICT
6
+ demo scenario must find a plan at the DEFAULT budget (no max_iterations override).
7
+ Blocker 2 — verdict split: GOAL_UNREACHABLE (proven impossible, decided promptly by a
8
+ reachability closure independent of the budget) vs SEARCH_EXHAUSTED(n) (budget ran
9
+ out; carries retry-with-higher-limit guidance). Two separate tests, one per branch
10
+ (NFR-1) — a combined test could pass by accident on either branch.
11
+ Blocker 3 — issuer_keys_available / source_class_verified split: the planner can no
12
+ longer fabricate a "verified" high_stakes plan with zero real key material
13
+ (configure_trusted_issuers is no longer a zero-precondition freebie), and both new
14
+ flags are structurally initial-state-only (preconditions somewhere, effects nowhere).
15
+
16
+ QE re-check additions (2026-08, findings GAP-2/5/6/7/8 of 08_qe_report.md):
17
+ honest SEARCH_EXHAUSTED wording pinned against the verification-blocked falsifier
18
+ (GAP-2); the `is None` identity hazard pinned as documented behavior (GAP-5);
19
+ max_seconds wall-clock ceiling bounds failure-path latency (GAP-6); PARANOID and
20
+ DEVELOPMENT default budgets exercised by shipped scenarios (GAP-7/GAP-8).
21
+
22
+ python3 -m unittest test_goap_planner -v
23
+ """
24
+
25
+ import sys
26
+
27
+ # Import NOTHING from this directory before this line. Running these tests used to
28
+ # leave a __pycache__ inside the skill directory, and this skill is vendored into 11
29
+ # copies kept byte-identical — so the stray directory read as canonical DRIFT and
30
+ # turned an unrelated repo test red. (Same guard as test_evidence_provenance.py.)
31
+ sys.dont_write_bytecode = True
32
+
33
+ import time
34
+ import unittest
35
+
36
+ from goap_planner import (
37
+ GOAPResearchPlanner,
38
+ PlanNotFound,
39
+ PlanVerdict,
40
+ RESEARCH_ACTIONS,
41
+ ResearchAction,
42
+ ResearchPlan,
43
+ VerificationMode,
44
+ _default_max_iterations,
45
+ _reachability_closure,
46
+ create_research_goal,
47
+ find_research_plan,
48
+ )
49
+
50
+ # A test double simulating a real signed-source integration: issuer key material
51
+ # genuinely held AND a source class capable of Ed25519-signed delivery. The
52
+ # package's honest default for the real health sources (PubMed/PMC/DOI/WHO) is
53
+ # that NEITHER fact holds.
54
+ _CAPABILITY_FLAGS = {"issuer_keys_available", "source_class_verified"}
55
+
56
+
57
+ def _verification_unreachable_scenario():
58
+ """The GAP-2 falsifier (QE re-check, 2026-08): a goal that is
59
+ CLOSURE-REACHABLE but permanently rejected by the STRICT unsigned-claims
60
+ gate. No action in this library can ever produce ``signed_facts`` or
61
+ ``cryptographically_verified``, so every goal node carries unsigned
62
+ claims and STRICT rejects it at EVERY budget (measured: search_exhausted
63
+ at max_iterations=1000 in 0.06s AND at 200000 in 14.16s). The 25 noise
64
+ actions keep open_set non-empty past any practical cap."""
65
+ actions = [ResearchAction(name="extract", preconditions={"start"},
66
+ effects={"claims_identified"}, cost=1, description="x")]
67
+ actions += [ResearchAction(name=f"n{i}", preconditions={"start"},
68
+ effects={f"f{i}"}, cost=1, description="n")
69
+ for i in range(25)]
70
+ return {"start"}, {"claims_identified"}, actions
71
+
72
+
73
+ class Blocker2VerdictSplitTests(unittest.TestCase):
74
+ """FR-2 / AC-2 / AC-3 / AM-2 — 'don't know' is a first-class verdict."""
75
+
76
+ def test_goal_unreachable_for_sentinel_key(self):
77
+ """AC-2: a goal fact no action's effects ever produce -> GOAL_UNREACHABLE,
78
+ decided promptly and independently of max_iterations (AM-2 confirmation:
79
+ near-identical wall-clock at budget 100 vs 200000 proves the verdict comes
80
+ from a real reachability check, not a relabeled timeout)."""
81
+ initial = {"topic_defined"}
82
+ goal = {"sentinel_fact_no_action_produces"}
83
+ # Sanity: the sentinel really is produced by no action.
84
+ for action in RESEARCH_ACTIONS:
85
+ self.assertNotIn("sentinel_fact_no_action_produces", action.effects)
86
+
87
+ timings = {}
88
+ for budget in (100, 200_000):
89
+ t0 = time.perf_counter()
90
+ result = find_research_plan(
91
+ initial, goal,
92
+ verification_mode=VerificationMode.MODERATE,
93
+ max_iterations=budget,
94
+ )
95
+ timings[budget] = time.perf_counter() - t0
96
+ self.assertIsInstance(result, PlanNotFound)
97
+ self.assertIs(result.verdict, PlanVerdict.GOAL_UNREACHABLE)
98
+ self.assertIsNone(result.iterations)
99
+ self.assertIn("sentinel_fact_no_action_produces", result.message)
100
+
101
+ # Promptness: both calls decided by the closure (plan budget: sub-10ms;
102
+ # asserted at 50ms for CI-noise margin), and budget-independent — a
103
+ # relabeled timeout at budget 200000 would burn >1s.
104
+ for budget, elapsed in timings.items():
105
+ self.assertLess(
106
+ elapsed, 0.05,
107
+ f"GOAL_UNREACHABLE at max_iterations={budget} took {elapsed:.4f}s — "
108
+ "not the prompt closure-based verdict the contract promises",
109
+ )
110
+ self.assertLess(abs(timings[100] - timings[200_000]), 0.05)
111
+
112
+ def test_search_exhausted_with_tiny_budget(self):
113
+ """AC-3 (red half): a genuinely-reachable goal at max_iterations=1 ->
114
+ SEARCH_EXHAUSTED(1) with concrete retry guidance, NOT GOAL_UNREACHABLE."""
115
+ initial, goal = create_research_goal("quick", VerificationMode.MODERATE)
116
+ result = find_research_plan(
117
+ initial, goal,
118
+ verification_mode=VerificationMode.MODERATE,
119
+ max_iterations=1,
120
+ )
121
+ self.assertIsInstance(result, PlanNotFound)
122
+ self.assertIs(result.verdict, PlanVerdict.SEARCH_EXHAUSTED)
123
+ self.assertEqual(result.iterations, 1)
124
+ # Actionable retry guidance: names the knob AND a concrete larger number.
125
+ self.assertIn("max_iterations", result.message)
126
+ self.assertIn("max_iterations=10", result.message)
127
+
128
+ def test_search_exhausted_retry_succeeds_with_larger_budget(self):
129
+ """AC-3 (green half): the SAME goal succeeds at a larger budget — proving
130
+ the SEARCH_EXHAUSTED verdict's retry advice is real, not decorative."""
131
+ initial, goal = create_research_goal("quick", VerificationMode.MODERATE)
132
+ result = find_research_plan(
133
+ initial, goal,
134
+ verification_mode=VerificationMode.MODERATE,
135
+ max_iterations=100,
136
+ )
137
+ self.assertIsInstance(result, ResearchPlan)
138
+
139
+ def test_plan_not_found_is_falsy(self):
140
+ """Defense in depth: a legacy `if plan:` TRUTHINESS check must degrade
141
+ to the old None semantics, never treat 'search exhausted' as 'plan
142
+ found'."""
143
+ result = PlanNotFound(verdict=PlanVerdict.SEARCH_EXHAUSTED, iterations=1)
144
+ self.assertFalse(bool(result))
145
+
146
+ def test_plan_not_found_is_not_none_identity_hazard(self):
147
+ """GAP-5 (QE re-check): the falsy safety net does NOT cover identity
148
+ checks — `result is None` is now always False, so a legacy
149
+ `if plan is not None:` caller reads a no-plan result as SUCCESS. This
150
+ test pins that hazard as documented (not accidental) behavior; the
151
+ loud warning lives in the PlanNotFound / find_research_plan / plan()
152
+ docstrings and in SKILL.md."""
153
+ initial, goal = create_research_goal("quick", VerificationMode.MODERATE)
154
+ result = find_research_plan(
155
+ initial, goal,
156
+ verification_mode=VerificationMode.MODERATE,
157
+ max_iterations=1,
158
+ )
159
+ self.assertIsInstance(result, PlanNotFound)
160
+ self.assertIsNotNone(result) # the documented hazard: identity checks lie
161
+ self.assertFalse(bool(result)) # while truthiness checks stay safe
162
+
163
+ def test_search_exhausted_message_honest_when_verification_gate_blocks(self):
164
+ """GAP-2 (QE re-check): the SEARCH_EXHAUSTED message must not assert
165
+ 'the budget was the limiting factor' — for a closure-reachable goal
166
+ that the STRICT unsigned-claims gate permanently rejects, NO budget
167
+ finds a plan (measured falsifier: search_exhausted at both
168
+ max_iterations=1000, 0.06s, and 200000, 14.16s). The message must say
169
+ what is actually true: closure passed => not PROVABLY unreachable,
170
+ which is NOT a guarantee a larger budget succeeds."""
171
+ initial, goal, actions = _verification_unreachable_scenario()
172
+
173
+ # Sanity 1: the goal IS closure-reachable (the relaxed check passes).
174
+ self.assertTrue(goal.issubset(_reachability_closure(initial, actions)))
175
+ # Sanity 2: the STRICT rejection is permanent — no action in the
176
+ # library can ever produce the facts that clear unsigned claims.
177
+ for action in actions:
178
+ self.assertNotIn("signed_facts", action.effects)
179
+ self.assertNotIn("cryptographically_verified", action.effects)
180
+
181
+ result = find_research_plan(
182
+ initial, goal, actions=actions,
183
+ verification_mode=VerificationMode.STRICT,
184
+ max_iterations=1000,
185
+ )
186
+ self.assertIsInstance(result, PlanNotFound)
187
+ self.assertIs(result.verdict, PlanVerdict.SEARCH_EXHAUSTED)
188
+ self.assertEqual(result.iterations, 1000)
189
+
190
+ msg = result.message
191
+ # The honest wording, pinned:
192
+ self.assertIn("not provably unreachable", msg)
193
+ self.assertIn("NOT a guarantee", msg)
194
+ self.assertIn("max_iterations", msg)
195
+ # The refuted over-claims, banned:
196
+ self.assertNotIn("budget was the limiting factor", msg)
197
+ self.assertNotIn("reachable in principle", msg)
198
+
199
+
200
+ class Blocker1AdaptiveBudgetTests(unittest.TestCase):
201
+ """FR-1 / AC-1 — the default budget covers the package's own demo scenarios."""
202
+
203
+ def test_default_budget_finds_high_stakes_demo_plan(self):
204
+ """AC-1: the shipped high_stakes/STRICT demo scenario (with the demo's
205
+ signed-source capability flags) finds a plan with NO max_iterations
206
+ argument passed."""
207
+ initial, goal = create_research_goal("high_stakes", VerificationMode.STRICT)
208
+ result = find_research_plan(
209
+ initial | _CAPABILITY_FLAGS, goal,
210
+ verification_mode=VerificationMode.STRICT,
211
+ )
212
+ self.assertIsInstance(result, ResearchPlan)
213
+ self.assertGreater(len(result.actions), 0)
214
+
215
+ def test_default_budget_finds_all_moderate_demo_scenarios(self):
216
+ """FR-1 calibration breadth: the MODERATE-mode shipped scenarios also
217
+ succeed at the adaptive default (calibrated across scenarios, not
218
+ extrapolated from one point)."""
219
+ for goal_type in ("exploratory", "competitive", "quick"):
220
+ with self.subTest(goal_type=goal_type):
221
+ initial, goal = create_research_goal(goal_type, VerificationMode.MODERATE)
222
+ result = find_research_plan(
223
+ initial, goal, verification_mode=VerificationMode.MODERATE
224
+ )
225
+ self.assertIsInstance(result, ResearchPlan)
226
+
227
+ def test_default_budget_formula(self):
228
+ """The adaptive default is mode-dominant (the MEASURED dominant variable)
229
+ with a per-goal headroom term, and floors match the calibrated constants."""
230
+ self.assertEqual(
231
+ _default_max_iterations(set("abcde"), VerificationMode.STRICT), 100_000
232
+ )
233
+ self.assertEqual(
234
+ _default_max_iterations(set("abcde"), VerificationMode.PARANOID), 100_000
235
+ )
236
+ self.assertEqual(
237
+ _default_max_iterations(set("abcde"), VerificationMode.MODERATE), 5_000
238
+ )
239
+ # GAP-8 (QE re-check): DEVELOPMENT reaches the MODERATE floor via the
240
+ # else-branch — asserted, not assumed.
241
+ self.assertEqual(
242
+ _default_max_iterations(set("abcde"), VerificationMode.DEVELOPMENT), 5_000
243
+ )
244
+ # Per-goal term takes over past the floor.
245
+ big_goal = {f"g{i}" for i in range(300)}
246
+ self.assertEqual(
247
+ _default_max_iterations(big_goal, VerificationMode.STRICT), 150_000
248
+ )
249
+ self.assertEqual(
250
+ _default_max_iterations(big_goal, VerificationMode.MODERATE), 15_000
251
+ )
252
+ self.assertEqual(
253
+ _default_max_iterations(big_goal, VerificationMode.DEVELOPMENT), 15_000
254
+ )
255
+
256
+ def test_default_budget_finds_high_stakes_paranoid_plan(self):
257
+ """GAP-7 (QE re-check): the PARANOID floor is no longer a bare
258
+ extrapolation — this shipped scenario exercises it. PARANOID's search
259
+ behavior is identical to STRICT in this planner (require_verification
260
+ is the only search-relevant switch, shared by both; the 0.99 threshold
261
+ affects reporting), so high_stakes/PARANOID must find a plan at the
262
+ DEFAULT budget exactly as the STRICT twin does."""
263
+ initial, goal = create_research_goal("high_stakes", VerificationMode.PARANOID)
264
+ result = find_research_plan(
265
+ initial | _CAPABILITY_FLAGS, goal,
266
+ verification_mode=VerificationMode.PARANOID,
267
+ )
268
+ self.assertIsInstance(result, ResearchPlan)
269
+ self.assertGreater(len(result.actions), 0)
270
+
271
+ def test_development_mode_plan_at_default_budget(self):
272
+ """GAP-8 (QE re-check): DEVELOPMENT mode is a working mode, not a dead
273
+ enum member — a shipped scenario plans successfully at its (MODERATE-
274
+ floor) default budget, with no verification gate applied."""
275
+ initial, goal = create_research_goal("exploratory", VerificationMode.DEVELOPMENT)
276
+ result = find_research_plan(
277
+ initial, goal, verification_mode=VerificationMode.DEVELOPMENT
278
+ )
279
+ self.assertIsInstance(result, ResearchPlan)
280
+
281
+ def test_explicit_override_still_respected(self):
282
+ """Regression guard from the plan (task 2.1): an explicit
283
+ max_iterations=200000 caller (the original repro command) still finds
284
+ the high_stakes plan — the sentinel-default only changes behavior when
285
+ the caller omits the argument."""
286
+ initial, goal = create_research_goal("high_stakes", VerificationMode.STRICT)
287
+ result = find_research_plan(
288
+ initial | _CAPABILITY_FLAGS, goal,
289
+ verification_mode=VerificationMode.STRICT,
290
+ max_iterations=200_000,
291
+ )
292
+ self.assertIsInstance(result, ResearchPlan)
293
+
294
+
295
+ class FailurePathLatencyTests(unittest.TestCase):
296
+ """GAP-6 (QE re-check) — the failure path must be boundable.
297
+
298
+ Raising the STRICT default budget 1,000 -> 100,000 made the FAILURE path
299
+ ~100x slower (measured on this class of scenario: 9.46s at the default
300
+ budget vs 0.06s at the old cap): any closure-passing goal the search
301
+ cannot satisfy burns the full budget. The fix is an opt-in wall-clock
302
+ ceiling (max_seconds) independent of the iteration count; the DEFAULT
303
+ stays None so the default search remains deterministic across machines
304
+ (decision recorded in ADR 03_adr/001, 'wall-clock default' rejected
305
+ option)."""
306
+
307
+ def test_max_seconds_bounds_failure_path_wall_clock(self):
308
+ """With max_seconds=0.5, the verification-blocked falsifier returns
309
+ SEARCH_EXHAUSTED well before burning the 100,000-iteration STRICT
310
+ default budget — bounded wall clock, honest verdict."""
311
+ initial, goal, actions = _verification_unreachable_scenario()
312
+ t0 = time.perf_counter()
313
+ result = find_research_plan(
314
+ initial, goal, actions=actions,
315
+ verification_mode=VerificationMode.STRICT, # default budget: 100,000
316
+ max_seconds=0.5,
317
+ )
318
+ elapsed = time.perf_counter() - t0
319
+ self.assertLess(
320
+ elapsed, 2.0,
321
+ f"failure path took {elapsed:.2f}s despite max_seconds=0.5 — "
322
+ "the wall-clock ceiling is not bounding latency",
323
+ )
324
+ self.assertIsInstance(result, PlanNotFound)
325
+ self.assertIs(result.verdict, PlanVerdict.SEARCH_EXHAUSTED)
326
+ # The ceiling, not the iteration cap, stopped the search — and the
327
+ # message says so instead of pretending the budget ran out.
328
+ self.assertLess(result.iterations, 100_000)
329
+ self.assertIn("max_seconds", result.message)
330
+ self.assertIn("not provably unreachable", result.message)
331
+
332
+ def test_max_seconds_does_not_cut_off_a_findable_plan(self):
333
+ """A generous ceiling never affects a scenario the budget satisfies:
334
+ the success path is unchanged."""
335
+ initial, goal = create_research_goal("quick", VerificationMode.MODERATE)
336
+ result = find_research_plan(
337
+ initial, goal,
338
+ verification_mode=VerificationMode.MODERATE,
339
+ max_seconds=30.0,
340
+ )
341
+ self.assertIsInstance(result, ResearchPlan)
342
+
343
+
344
+ class Blocker3CapabilityFlagSplitTests(unittest.TestCase):
345
+ """FR-3 / AC-4 / AM-1 — no more free 'verified' plans without real keys."""
346
+
347
+ def test_high_stakes_reachable_with_keys_and_source_class(self):
348
+ """AC-4a: with a test double supplying both capability facts (a real
349
+ signed-source integration), high_stakes/STRICT finds a plan — exercised
350
+ through the high-level planner so the constructor wiring is covered."""
351
+ planner = GOAPResearchPlanner(
352
+ verification_mode="strict",
353
+ issuer_keys_available=True,
354
+ source_class_verified=True,
355
+ )
356
+ result = planner.plan(goal_type="high_stakes", topic="test")
357
+ self.assertIsInstance(result, ResearchPlan)
358
+
359
+ def test_high_stakes_unreachable_with_default_no_keys(self):
360
+ """AC-4b / AM-1 confirmation: the package's REAL default (no genuine
361
+ issuer keys) yields GOAL_UNREACHABLE — never a silently-fabricated
362
+ 'verified' plan. Reverting configure_trusted_issuers.preconditions to
363
+ set() flips this test to a fabricated ResearchPlan (proven in the
364
+ discrimination cycle), so this test is the safeguard's tripwire."""
365
+ initial, goal = create_research_goal("high_stakes", VerificationMode.STRICT)
366
+ result = find_research_plan(
367
+ initial, goal, verification_mode=VerificationMode.STRICT
368
+ )
369
+ self.assertIsInstance(result, PlanNotFound)
370
+ self.assertIs(result.verdict, PlanVerdict.GOAL_UNREACHABLE)
371
+
372
+ # Same honest default through the high-level planner: a bare issuer
373
+ # DOMAIN LIST is not key possession.
374
+ planner = GOAPResearchPlanner(
375
+ verification_mode="strict",
376
+ trusted_issuers=["pubmed.ncbi.nlm.nih.gov", "who.int"],
377
+ )
378
+ result = planner.plan(goal_type="high_stakes", topic="test")
379
+ self.assertIsInstance(result, PlanNotFound)
380
+ self.assertIs(result.verdict, PlanVerdict.GOAL_UNREACHABLE)
381
+
382
+ def test_partial_flags_still_unreachable(self):
383
+ """Two axes are genuinely two axes: neither flag alone unlocks
384
+ high_stakes — keys without a verifiable source class (and vice versa)
385
+ still yield GOAL_UNREACHABLE."""
386
+ initial, goal = create_research_goal("high_stakes", VerificationMode.STRICT)
387
+ for only in sorted(_CAPABILITY_FLAGS):
388
+ with self.subTest(only_flag=only):
389
+ result = find_research_plan(
390
+ initial | {only}, goal,
391
+ verification_mode=VerificationMode.STRICT,
392
+ )
393
+ self.assertIsInstance(result, PlanNotFound)
394
+ self.assertIs(result.verdict, PlanVerdict.GOAL_UNREACHABLE)
395
+
396
+ def test_new_flags_are_preconditions_never_effects(self):
397
+ """FR-3 structural guard (layer-1 deterministic check): each new flag is
398
+ READ as a precondition somewhere (the split is not cosmetic — the dead
399
+ whitelist_available mistake is not repeated) and PRODUCED by no action
400
+ (no action may manufacture its own key/source-class credibility)."""
401
+ for flag in sorted(_CAPABILITY_FLAGS):
402
+ with self.subTest(flag=flag):
403
+ self.assertTrue(
404
+ any(flag in a.preconditions for a in RESEARCH_ACTIONS),
405
+ f"{flag} is never read as a precondition — the split is cosmetic",
406
+ )
407
+ producers = [a.name for a in RESEARCH_ACTIONS if flag in a.effects]
408
+ self.assertEqual(
409
+ producers, [],
410
+ f"{flag} is produced for free by {producers} — the "
411
+ "whitelist_available defect reintroduced",
412
+ )
413
+ # And the dead flag itself is gone from the action graph entirely.
414
+ for action in RESEARCH_ACTIONS:
415
+ self.assertNotIn("whitelist_available", action.preconditions)
416
+ self.assertNotIn("whitelist_available", action.effects)
417
+
418
+
419
+ if __name__ == "__main__":
420
+ unittest.main()