prodc 0.3.66__tar.gz → 0.3.72__tar.gz

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 (59) hide show
  1. {prodc-0.3.66 → prodc-0.3.72}/PKG-INFO +1 -1
  2. prodc-0.3.72/examples/quickstart/needs/jordan.yaml +40 -0
  3. {prodc-0.3.66 → prodc-0.3.72}/examples/quickstart/prodc.toml +5 -1
  4. {prodc-0.3.66 → prodc-0.3.72}/pyproject.toml +1 -1
  5. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/adapters.py +111 -0
  6. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/board.py +2 -0
  7. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/render.py +2 -0
  8. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/config.py +9 -1
  9. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/loader.py +26 -0
  10. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/model.py +23 -0
  11. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/status.py +101 -6
  12. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/writer.py +19 -0
  13. prodc-0.3.72/tests/test_answer.py +154 -0
  14. prodc-0.3.72/tests/test_citation.py +39 -0
  15. prodc-0.3.72/tests/test_claim_gating.py +66 -0
  16. prodc-0.3.72/tests/test_evalgate.py +100 -0
  17. prodc-0.3.72/tests/test_needref.py +175 -0
  18. prodc-0.3.66/examples/quickstart/needs/jordan.yaml +0 -24
  19. {prodc-0.3.66 → prodc-0.3.72}/.gitignore +0 -0
  20. {prodc-0.3.66 → prodc-0.3.72}/CHANGELOG.md +0 -0
  21. {prodc-0.3.66 → prodc-0.3.72}/LICENSE +0 -0
  22. {prodc-0.3.66 → prodc-0.3.72}/README.md +0 -0
  23. {prodc-0.3.66 → prodc-0.3.72}/examples/quickstart/features/orders.feature +0 -0
  24. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/__init__.py +0 -0
  25. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/agent.md +0 -0
  26. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/callbacks.py +0 -0
  27. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/__init__.py +0 -0
  28. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/app.py +0 -0
  29. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/commands.py +0 -0
  30. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/common.py +0 -0
  31. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/serialize.py +0 -0
  32. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/dedup.py +0 -0
  33. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/py.typed +0 -0
  34. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/report.py +0 -0
  35. {prodc-0.3.66 → prodc-0.3.72}/src/prodc/report_html.py +0 -0
  36. {prodc-0.3.66 → prodc-0.3.72}/tests/test_adapters.py +0 -0
  37. {prodc-0.3.66 → prodc-0.3.72}/tests/test_callbacks.py +0 -0
  38. {prodc-0.3.66 → prodc-0.3.72}/tests/test_cli.py +0 -0
  39. {prodc-0.3.66 → prodc-0.3.72}/tests/test_command_hook.py +0 -0
  40. {prodc-0.3.66 → prodc-0.3.72}/tests/test_dedup.py +0 -0
  41. {prodc-0.3.66 → prodc-0.3.72}/tests/test_description.py +0 -0
  42. {prodc-0.3.66 → prodc-0.3.72}/tests/test_drafts.py +0 -0
  43. {prodc-0.3.66 → prodc-0.3.72}/tests/test_external_adapter.py +0 -0
  44. {prodc-0.3.66 → prodc-0.3.72}/tests/test_gitlab_source.py +0 -0
  45. {prodc-0.3.66 → prodc-0.3.72}/tests/test_importance.py +0 -0
  46. {prodc-0.3.66 → prodc-0.3.72}/tests/test_loader.py +0 -0
  47. {prodc-0.3.66 → prodc-0.3.72}/tests/test_models.py +0 -0
  48. {prodc-0.3.66 → prodc-0.3.72}/tests/test_report.py +0 -0
  49. {prodc-0.3.66 → prodc-0.3.72}/tests/test_report_html.py +0 -0
  50. {prodc-0.3.66 → prodc-0.3.72}/tests/test_roles.py +0 -0
  51. {prodc-0.3.66 → prodc-0.3.72}/tests/test_sources.py +0 -0
  52. {prodc-0.3.66 → prodc-0.3.72}/tests/test_staleness.py +0 -0
  53. {prodc-0.3.66 → prodc-0.3.72}/tests/test_status.py +0 -0
  54. {prodc-0.3.66 → prodc-0.3.72}/tests/test_suggest.py +0 -0
  55. {prodc-0.3.66 → prodc-0.3.72}/tests/test_variants.py +0 -0
  56. {prodc-0.3.66 → prodc-0.3.72}/tests/test_who_variants.py +0 -0
  57. {prodc-0.3.66 → prodc-0.3.72}/tests/test_withdrawn.py +0 -0
  58. {prodc-0.3.66 → prodc-0.3.72}/tests/test_writer.py +0 -0
  59. {prodc-0.3.66 → prodc-0.3.72}/tests/test_writer_comments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.66
3
+ Version: 0.3.72
4
4
  Summary: Product management as code: roles, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
6
6
  Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
@@ -0,0 +1,40 @@
1
+ who: jordan
2
+ label: "design partner"
3
+ needs:
4
+ - id: N-1
5
+ text: "see which orders are past due at a glance"
6
+ must: true
7
+ sources:
8
+ - kind: primary
9
+ who: Jordan
10
+ date: 2026-09-20
11
+ quote: "I lose the past-due ones in the list"
12
+ proofs:
13
+ - "web:orders.feature#Past due orders are highlighted"
14
+
15
+ - id: N-2
16
+ text: "export the day's delivery run sheet"
17
+ sources:
18
+ - kind: secondary
19
+ locator: issues.md#N-2
20
+ date: 2026-09-21
21
+ proofs: [] # sourced HOLE: the need is evidenced, but no test proves it yet
22
+
23
+ - id: N-3
24
+ text: "implement a settings page" # opinion (no source) + solution-in-disguise warning
25
+
26
+ # A product need is a hypothesis, same as a research question. Stating the `claim` (the warrant
27
+ # the bet would establish) silences the EARS/solution-verb lint — the honest act, not a mode.
28
+ # The experiment shipped and settled the question NO: DELIVERED + answer: no. It stays on the
29
+ # board and counts as a result — unlike `withdrawn`, which would hide that something was learned.
30
+ - id: N-4
31
+ text: "pin the daily summary to the top of the dashboard"
32
+ claim: "pinning the summary lifts next-day return visits"
33
+ sources:
34
+ - kind: primary
35
+ who: Jordan
36
+ date: 2026-09-22
37
+ quote: "I never scroll back up to the summary"
38
+ proofs:
39
+ - "ship:jordan@2026-10-05" # the A/B experiment ran and concluded
40
+ answer: no # return visits did not move — the bet is settled, and the answer is no
@@ -1,7 +1,8 @@
1
1
  # A minimal, runnable prodc project. From this directory:
2
2
  # uv run prodc status
3
3
  # N-1 shows DELIVERED (a tagged scenario proves it), N-2 is a sourced HOLE
4
- # (evidence exists, no test yet), N-3 is an opinion with a solution-in-disguise warning.
4
+ # (evidence exists, no test yet), N-3 is an opinion with a solution-in-disguise warning,
5
+ # N-4 is a shipped hypothesis that settled NO (DELIVERED + answer: no — a result, not a gap).
5
6
 
6
7
  [prodc]
7
8
  needs = ["needs/*.yaml"]
@@ -11,3 +12,6 @@ kind = "gherkin"
11
12
  paths = ["features/*.feature"]
12
13
  delivered = ["@implemented"]
13
14
  pending = ["@pending"]
15
+
16
+ [proofs.ship]
17
+ kind = "accept" # a human records that an experiment ran and concluded (declared)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.66"
3
+ version = "0.3.72"
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -40,6 +40,23 @@ def _steps_passed(steps: list[object]) -> bool:
40
40
  return True
41
41
 
42
42
 
43
+ def _as_score(val: object) -> float | None:
44
+ """A numeric eval score from a results value: a bare number, or ``{"score": N}``.
45
+
46
+ ``bool`` is a subclass of ``int`` but is a pass/fail, not a score, so it is rejected — a
47
+ boolean result belongs to :class:`OutcomeAdapter`, not the gate.
48
+ """
49
+ if isinstance(val, bool):
50
+ return None
51
+ if isinstance(val, (int, float)):
52
+ return float(val)
53
+ if isinstance(val, dict):
54
+ inner = cast("dict[str, object]", val).get("score")
55
+ if isinstance(inner, (int, float)) and not isinstance(inner, bool):
56
+ return float(inner)
57
+ return None
58
+
59
+
43
60
  def _git_head(repo: Path) -> str | None:
44
61
  """Short git rev of a checked-out repo, read from .git/HEAD (no git binary needed)."""
45
62
  head = repo / ".git" / "HEAD"
@@ -486,6 +503,79 @@ class OutcomeAdapter:
486
503
  return ProofResult(ProofState.PENDING, False, f"outcome '{payload}' recorded failing")
487
504
 
488
505
 
506
+ #: Comparators an evalgate locator may use, strongest-looking first so ``>=``/``<=`` match
507
+ #: before the bare ``>``/``<`` when scanning the locator.
508
+ _EVAL_OPS: dict[str, Callable[[float, float], bool]] = {
509
+ ">=": lambda s, t: s >= t,
510
+ "<=": lambda s, t: s <= t,
511
+ ">": lambda s, t: s > t,
512
+ "<": lambda s, t: s < t,
513
+ }
514
+ _EVAL_RE = re.compile(r"^(?P<key>.+?)(?P<op><=|>=|<|>)(?P<thr>[^<>=]+)$")
515
+
516
+
517
+ class EvalGateAdapter:
518
+ """Resolves ``metric>=threshold`` against a recorded eval score (#75).
519
+
520
+ A scored sibling of :class:`OutcomeAdapter`: where that reads a pass/fail bool, this reads a
521
+ numeric score and gates it against a threshold carried in the locator (``accuracy>=0.9``), so
522
+ one adapter serves many metrics and cutoffs (``>=``, ``<=``, ``>``, ``<`` — ``<=`` for a loss
523
+ you want below a bound). The score is produced by an eval harness *outside* prodc and recorded
524
+ in a JSON file (``{"accuracy": {"score": 0.82}}`` or the bare ``{"accuracy": 0.82}``); prodc
525
+ only reads it — records-only, never runs the eval.
526
+
527
+ DELIVERED when the comparison holds; PENDING when the score is recorded but does not meet the
528
+ gate, or the metric is absent from a present file (recorded-below and not-yet-recorded are both
529
+ "not green", as in :class:`OutcomeAdapter`); BROKEN when the results file is absent or the
530
+ locator is malformed. The score, comparator and threshold go in the detail so the board shows
531
+ how far off it is.
532
+ """
533
+
534
+ def __init__(self, root: Path, results: str) -> None:
535
+ self._path = root / results
536
+ self._data: dict[str, float] | None = None
537
+ self._loaded = False
538
+
539
+ def _load(self) -> dict[str, float] | None:
540
+ if not self._loaded:
541
+ self._loaded = True
542
+ if self._path.exists():
543
+ out: dict[str, float] = {}
544
+ raw: object = json.loads(self._path.read_text(encoding="utf-8"))
545
+ if isinstance(raw, dict):
546
+ for key, val in cast("dict[str, object]", raw).items():
547
+ score = _as_score(val)
548
+ if score is not None:
549
+ out[key] = score
550
+ self._data = out
551
+ return self._data
552
+
553
+ def resolve(self, payload: str) -> ProofResult:
554
+ m = _EVAL_RE.match(payload.strip())
555
+ if not m:
556
+ return ProofResult(
557
+ ProofState.BROKEN, False, f"malformed evalgate '{payload}' (want metric>=threshold)"
558
+ )
559
+ key = m.group("key").strip()
560
+ op = m.group("op")
561
+ try:
562
+ threshold = float(m.group("thr").strip())
563
+ except ValueError:
564
+ return ProofResult(
565
+ ProofState.BROKEN, False, f"evalgate threshold not a number '{payload}'"
566
+ )
567
+ data = self._load()
568
+ if data is None:
569
+ return ProofResult(ProofState.BROKEN, False, f"no results file '{self._path.name}'")
570
+ if key not in data:
571
+ return ProofResult(ProofState.PENDING, False, f"metric '{key}' not in results")
572
+ score = data[key]
573
+ passed = _EVAL_OPS[op](score, threshold)
574
+ verdict = "pass" if passed else "below gate"
575
+ detail = f"{key} {score:g} {op} {threshold:g} ({verdict})"
576
+ return ProofResult(ProofState.DELIVERED if passed else ProofState.PENDING, False, detail)
577
+
578
+
489
579
  class CommandProof:
490
580
  """Resolves a locator by running a user-supplied command — a proof hook (#70).
491
581
 
@@ -549,6 +639,27 @@ class PrototypeAdapter:
549
639
  return ProofResult(ProofState.DELIVERED, True, detail)
550
640
 
551
641
 
642
+ class CitationAdapter:
643
+ """Resolves ``ref@author@year`` to a cited published result — a declared DELIVERED (#75).
644
+
645
+ A research question can be settled by citing a result a human vouches for and prodc cannot
646
+ check (a published paper, a textbook theorem). Modelled on :class:`PrototypeAdapter`: a
647
+ *declared* DELIVERED (``*``), never BROKEN, because it is an assertion, like a tag. Kept
648
+ distinct from ``accept`` on purpose — ``accept`` records *who signed off*; a citation records
649
+ *what external result settles it*, and ``ref`` (the thing a reader follows to check the claim)
650
+ is the load-bearing payload ``accept`` has no slot for: ``accept:Smith2019@jorge@...`` would
651
+ read as "jorge accepted it", hiding that the truth is "Smith 2019 shows it".
652
+ """
653
+
654
+ def resolve(self, payload: str) -> ProofResult:
655
+ ref, _, rest = payload.partition("@")
656
+ author, _, year = rest.partition("@")
657
+ detail = "cites " + (ref or "?")
658
+ detail += f" by {author}" if author else ""
659
+ detail += f" ({year})" if year else ""
660
+ return ProofResult(ProofState.DELIVERED, True, detail)
661
+
662
+
552
663
  class External:
553
664
  """Wraps a proof adapter that resolves against a sibling repo checked out at ``repo``.
554
665
 
@@ -97,6 +97,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
97
97
  "goal": need.goal,
98
98
  "release": need.release,
99
99
  "claim": need.claim,
100
+ "answer": need.answer.value if need.answer else None,
100
101
  "withdrawn": (
101
102
  {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
102
103
  if need.withdrawn
@@ -143,6 +144,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
143
144
  "release": need.release,
144
145
  "product": need.product,
145
146
  "draft": need.draft,
147
+ "answer": need.answer.value if need.answer else None,
146
148
  "withdrawn": (
147
149
  {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
148
150
  if need.withdrawn
@@ -273,6 +273,8 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
273
273
  f" grounds: {_LABEL[r.state]}{nm} warrant: {_warrant(need)}",
274
274
  f" shape: {ears_shape(need.text).value}",
275
275
  ]
276
+ if need.answer:
277
+ lines.append(f" answer: {need.answer.value}")
276
278
  if need.withdrawn:
277
279
  lines.append(f" withdrawn: {need.withdrawn.reason} ({need.withdrawn.date})")
278
280
  if need.description:
@@ -10,9 +10,11 @@ from typing import cast
10
10
  from .adapters import (
11
11
  AcceptAdapter,
12
12
  Adapter,
13
+ CitationAdapter,
13
14
  CommandProof,
14
15
  CommandSource,
15
16
  CoverageAdapter,
17
+ EvalGateAdapter,
16
18
  External,
17
19
  FieldAdapter,
18
20
  FilesAdapter,
@@ -190,6 +192,8 @@ class Config:
190
192
  inner = AcceptAdapter()
191
193
  elif kind == "prototype":
192
194
  inner = PrototypeAdapter()
195
+ elif kind == "citation":
196
+ inner = CitationAdapter()
193
197
  elif kind == "coverage":
194
198
  cat = tbl.get("catalog", "")
195
199
  inner = CoverageAdapter(
@@ -202,13 +206,17 @@ class Config:
202
206
  if not results_s:
203
207
  raise ConfigError(f"[proofs.{name}]: external needs results = <json file>")
204
208
  inner = OutcomeAdapter(root=proof_root, results=results_s)
209
+ elif kind == "evalgate":
210
+ if not results_s:
211
+ raise ConfigError(f"[proofs.{name}]: evalgate needs results = <json file>")
212
+ inner = EvalGateAdapter(root=proof_root, results=results_s)
205
213
  elif kind == "command":
206
214
  command, timeout = _hook_command(tbl, f"[proofs.{name}]")
207
215
  inner = CommandProof(root=proof_root, command=command, timeout=timeout)
208
216
  else:
209
217
  raise ConfigError(
210
218
  f"[proofs.{name}]: unknown kind {kind!r} "
211
- "(gherkin|pytest|files|accept|prototype|coverage|field|external|command)"
219
+ "(gherkin|pytest|files|accept|prototype|citation|coverage|field|external|evalgate|command)"
212
220
  )
213
221
  adapters[name] = (
214
222
  External(inner, root / repo_field) if isinstance(repo_field, str) else inner
@@ -17,6 +17,7 @@ import yaml
17
17
 
18
18
  from .config import Config
19
19
  from .model import (
20
+ Answer,
20
21
  ArgKind,
21
22
  ArgNode,
22
23
  Attestation,
@@ -105,6 +106,29 @@ def _attestation(raw: object, where: str) -> Attestation | None:
105
106
  )
106
107
 
107
108
 
109
+ def _answer(raw: object, where: str) -> Answer | None:
110
+ """Parse ``answer: yes|no|inconclusive`` into an :class:`Answer`, validated like other enums.
111
+
112
+ The direction a settled research question resolved; absent on a product need. Same
113
+ ``Enum(name) except ValueError`` pattern as ``SourceKind``/``ArgKind`` so an unknown value is
114
+ a :class:`LoaderError` the author owns, not silent junk.
115
+
116
+ YAML 1.1 reads a bare ``yes``/``no`` as a boolean, so ``answer: no`` arrives as ``False`` —
117
+ coerced back to the string here, the same way ``_opt_str`` accepts a bare ``date:`` that
118
+ parsed as a date. ``inconclusive`` is a plain string and needs no coercion.
119
+ """
120
+ if raw is None:
121
+ return None
122
+ if isinstance(raw, bool):
123
+ raw = "yes" if raw else "no"
124
+ name = _as_str(raw, where)
125
+ try:
126
+ return Answer(name)
127
+ except ValueError:
128
+ answers = "|".join(a.value for a in Answer)
129
+ raise LoaderError(f"{where}: unknown answer {name!r} ({answers})") from None
130
+
131
+
108
132
  def _withdrawal(raw: object, where: str) -> Withdrawal | None:
109
133
  """Parse ``withdrawn: {reason, date}`` into a :class:`Withdrawal` (#74).
110
134
 
@@ -178,6 +202,7 @@ def _need(
178
202
  product = _as_bool(tbl["product"], f"{where}.product") if "product" in tbl else True
179
203
  draft = _as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False
180
204
  withdrawn = _withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn")
205
+ answer = _answer(tbl.get("answer"), f"{where}.answer")
181
206
 
182
207
  sources = tuple(
183
208
  _source(s, f"{where}.sources[{i}]")
@@ -236,6 +261,7 @@ def _need(
236
261
  importance_set_at=importance_set_at,
237
262
  draft=draft,
238
263
  withdrawn=withdrawn,
264
+ answer=answer,
239
265
  )
240
266
 
241
267
 
@@ -48,6 +48,26 @@ class NeedState(StrEnum):
48
48
  UNRESOLVED = "unresolved" # a proof's source could not be read (e.g. sibling repo absent)
49
49
 
50
50
 
51
+ class Answer(StrEnum):
52
+ """Which way a settled question resolved — the research board's direction axis.
53
+
54
+ A product need has one axis: is the outcome achieved? A research question has two: is it
55
+ *settled* (DELIVERED, the proof resolved green) and, if so, *which way*. DELIVERED means
56
+ settled; ``answer`` records the direction. A disproven hypothesis is therefore a settled
57
+ result that still counts toward coverage — DELIVERED with ``answer: no`` — not a gap.
58
+
59
+ Direction is always authored, never computed: an adapter can only map a proof to
60
+ delivered/pending/broken/unresolved, so no adapter can emit a settled-NO. The act of
61
+ concluding "this is the answer, and it is no" is the author's decision, so ``answer`` lives
62
+ on the :class:`Need` (authored lifecycle), not on a proof or need result (computed). A product
63
+ board never sets it and behaves exactly as before.
64
+ """
65
+
66
+ YES = "yes" # the question settled affirmatively
67
+ NO = "no" # the question settled negatively — a result, not a gap (e.g. a disproven hypothesis)
68
+ INCONCLUSIVE = "inconclusive" # settled that the evidence cannot decide it either way
69
+
70
+
51
71
  @dataclass(frozen=True)
52
72
  class Source:
53
73
  """Where a need came from. Absence of any Source makes the need an opinion."""
@@ -179,6 +199,9 @@ class Need:
179
199
  product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
180
200
  draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
181
201
  withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
202
+ answer: Answer | None = (
203
+ None # which way a settled need resolved (yes/no/inconclusive); None=open-ended
204
+ )
182
205
  not_duplicate_of: tuple[str, ...] = () # ids this need is confirmed NOT a duplicate of (#55)
183
206
  not_variant_of: tuple[str, ...] = () # ids this need is confirmed NOT a shared variant of (#56)
184
207
 
@@ -15,6 +15,7 @@ from .adapters import Adapter, Enumerable, Inventoried, ProofResult, SourceAdapt
15
15
  from .dedup import similarity
16
16
  from .model import (
17
17
  SOLUTION_VERBS,
18
+ Answer,
18
19
  ArgKind,
19
20
  ArgNode,
20
21
  AttestationKind,
@@ -29,14 +30,27 @@ from .model import (
29
30
  ears_warnings,
30
31
  )
31
32
 
33
+ #: Reserved proof-adapter name for a need→need edge (#research-board step 2): a proof whose
34
+ #: payload is another need id. Resolved by the :class:`Resolver` itself, not a config adapter,
35
+ #: so it never appears in ``[proofs.*]``; a ``[proofs.need]`` block would be shadowed by this.
36
+ _NEEDREF = "need"
37
+
32
38
 
33
39
  def _text_warnings(need: Need) -> list[str]:
34
- first = need.text.strip().split(" ", 1)[0].lower().rstrip(":")
35
40
  out: list[str] = []
36
- if first in SOLUTION_VERBS:
37
- out.append(f"text may be a solution in disguise (starts with '{first}')")
38
- else:
39
- out.extend(ears_warnings(need.text)) # EARS shape check (redundant on solution verbs)
41
+ # The outcome-shape lint (EARS + solution-verb) fires only until the author states the
42
+ # warrant. A need with `claim` set has already articulated the proposition the lint was
43
+ # prodding toward — "what delivering this establishes" — so it is silenced. This is what
44
+ # unifies a product board and a research board without a mode: "When a roaster opens the
45
+ # queue, she can see today's orders" and "π is irrational" are the same claim-under-uncertainty
46
+ # at different altitudes; EARS is just one falsifiable-claim grammar, a stated `claim` another.
47
+ if need.claim is None:
48
+ first = need.text.strip().split(" ", 1)[0].lower().rstrip(":")
49
+ if first in SOLUTION_VERBS:
50
+ out.append(f"text may be a solution in disguise (starts with '{first}')")
51
+ else:
52
+ out.extend(ears_warnings(need.text)) # EARS shape check (redundant on solution verbs)
53
+ # Source hygiene is universal (it is about evidence, not phrasing) — always run it.
40
54
  for src in need.sources:
41
55
  out.extend(src.warnings())
42
56
  return out
@@ -110,6 +124,23 @@ def _warrant_warnings(need: Need, result: NeedResult) -> list[str]:
110
124
  return []
111
125
 
112
126
 
127
+ def _answer_warnings(need: Need, result: NeedResult) -> list[str]:
128
+ """Flag an ``answer`` authored on a question that is not settled (research board).
129
+
130
+ DELIVERED means settled; ``answer`` records which way it settled. An answer declared while
131
+ the state is anything but DELIVERED is a claim the proofs do not yet support — surface it.
132
+ Conservative by design: the check never cross-examines the proof detail against the
133
+ direction (a ``command:lean/¬P`` counterexample legitimately pairs DELIVERED with ``no``),
134
+ so only settledness is checked here, not which way.
135
+ """
136
+ if need.answer is not None and result.state is not NeedState.DELIVERED:
137
+ return [
138
+ f"answer '{need.answer.value}' declared on an unsettled question "
139
+ f"(state {result.state.value})"
140
+ ]
141
+ return []
142
+
143
+
113
144
  class Resolver:
114
145
  def __init__(
115
146
  self,
@@ -139,6 +170,7 @@ class Resolver:
139
170
  _text_warnings(need)
140
171
  + _source_warnings(need, self._source_adapters)
141
172
  + _warrant_warnings(need, result)
173
+ + _answer_warnings(need, result)
142
174
  + result.warnings
143
175
  )
144
176
  self._cache[need_id] = result
@@ -149,10 +181,73 @@ class Resolver:
149
181
  return self._aggregate_children(need)
150
182
  return self._resolve_leaf(need)
151
183
 
184
+ def _resolve_one(self, locator: str) -> ProofResult:
185
+ """Resolve one leaf proof locator, routing ``need:`` to the needref edge.
186
+
187
+ Every other adapter reads a repo; a needref reads another need's resolution, which only
188
+ the resolver can do — so it is dispatched here, not via :func:`_resolve_proof`.
189
+ """
190
+ name, sep, payload = locator.partition(":")
191
+ if sep and name == _NEEDREF:
192
+ return self._resolve_needref(payload)
193
+ return _resolve_proof(locator, self._adapters)
194
+
195
+ def _resolve_needref(self, payload: str) -> ProofResult:
196
+ """Resolve ``need:<id>`` (or ``need:<id>@<answer>``) against another need (#research step 2).
197
+
198
+ A research question can depend on a peer: "B cannot be posed until A is settled." That
199
+ edge is itself a proof — B delivers iff A is settled — so it rides the ``proofs`` tuple
200
+ with no new field, and the resolver's own cycle detection and memoization cover A→B→A for
201
+ free (a back-edge hits the ``_visiting`` guard and returns BROKEN). ``need:A`` resolves on
202
+ A's *settledness* (DELIVERED); the optional ``need:A@yes`` form additionally requires A's
203
+ authored :attr:`~prodc.model.Need.answer` to match. B never inherits A's answer — the
204
+ dependency only requires A settled, optionally in a given direction.
205
+ """
206
+ ref_id, sep, direction = payload.partition("@")
207
+ ref_id = ref_id.strip()
208
+ if ref_id not in self._by_id:
209
+ return ProofResult(ProofState.BROKEN, False, f"need '{ref_id}' is not a known need id")
210
+ want: Answer | None = None
211
+ if sep:
212
+ try:
213
+ want = Answer(direction.strip())
214
+ except ValueError:
215
+ answers = "|".join(a.value for a in Answer)
216
+ return ProofResult(
217
+ ProofState.BROKEN, False, f"unknown answer '{direction}' ({answers})"
218
+ )
219
+ cr = self.resolve(ref_id) # cycle-safe via the _visiting guard in resolve()
220
+ if cr.state is NeedState.BROKEN:
221
+ return ProofResult(ProofState.BROKEN, False, f"need '{ref_id}' unresolved (broken)")
222
+ if cr.state is NeedState.UNRESOLVED:
223
+ return ProofResult(ProofState.UNRESOLVED, False, f"need '{ref_id}' could not be read")
224
+ if cr.state is not NeedState.DELIVERED:
225
+ return ProofResult(
226
+ ProofState.PENDING, False, f"need '{ref_id}' not settled ({cr.state.value})"
227
+ )
228
+ if want is None:
229
+ return ProofResult(ProofState.DELIVERED, cr.declared, f"need '{ref_id}' settled")
230
+ answer = self._by_id[ref_id].answer
231
+ if answer is None:
232
+ return ProofResult(
233
+ ProofState.PENDING,
234
+ False,
235
+ f"need '{ref_id}' settled, no answer recorded, awaiting '{want.value}'",
236
+ )
237
+ if answer is want:
238
+ return ProofResult(
239
+ ProofState.DELIVERED, cr.declared, f"need '{ref_id}' settled: {answer.value}"
240
+ )
241
+ return ProofResult(
242
+ ProofState.PENDING,
243
+ False,
244
+ f"need '{ref_id}' settled {answer.value}, awaiting '{want.value}'",
245
+ )
246
+
152
247
  def _resolve_leaf(self, need: Need) -> NeedResult:
153
248
  if not need.proofs:
154
249
  return NeedResult(need, NeedState.HOLE)
155
- results = [_resolve_proof(p, self._adapters) for p in need.proofs]
250
+ results = [self._resolve_one(p) for p in need.proofs]
156
251
  detail = [
157
252
  f"{p} → {r.state.value}{'*' if r.declared else ''} ({r.detail})"
158
253
  for p, r in zip(need.proofs, results, strict=True)
@@ -167,6 +167,24 @@ def _item_id(lines: list[str], start: int, item_indent: int) -> str | None:
167
167
  return None
168
168
 
169
169
 
170
+ def _normalize_answer(doc: dict[str, object]) -> None:
171
+ """Rewrite a need's ``answer`` back to its yes/no string before re-dump.
172
+
173
+ YAML 1.1 reads a bare ``answer: no`` as the boolean ``False``, so the raw dict carries a
174
+ bool; dumping it as-is would write ``answer: false`` — loads fine (the loader coerces it),
175
+ but a reader sees ``false``, not the authored ``no``. Canonicalise here so the on-disk text
176
+ stays the research vocabulary, the same spirit as ``_canonical_kind`` for source kinds.
177
+ """
178
+ needs = doc.get("needs")
179
+ if not isinstance(needs, list):
180
+ return
181
+ for raw in cast("list[object]", needs):
182
+ if isinstance(raw, dict):
183
+ need = cast("dict[str, object]", raw)
184
+ if isinstance(need.get("answer"), bool):
185
+ need["answer"] = "yes" if need["answer"] else "no"
186
+
187
+
170
188
  def _render_edit(old_text: str, doc: dict[str, object], need_id: str) -> str:
171
189
  """Re-render after an in-place edit, splicing only the changed need to keep comments (#53).
172
190
 
@@ -175,6 +193,7 @@ def _render_edit(old_text: str, doc: dict[str, object], need_id: str) -> str:
175
193
  behaviour) when the file's shape is anything the splice isn't sure of — the reload gate in
176
194
  :func:`_validated` guards correctness either way.
177
195
  """
196
+ _normalize_answer(doc)
178
197
  lines = old_text.splitlines(keepends=True)
179
198
  span = _need_block_span(lines, need_id)
180
199
  needs = doc.get("needs")
@@ -0,0 +1,154 @@
1
+ """Research-board `answer` axis: DELIVERED means a question is *settled*; `answer`
2
+ (yes/no/inconclusive) records which way. A settled-NO is a result that still counts toward
3
+ coverage, not a gap. Authored on the Need, never computed — a product board never sets it."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from prodc.config import Config
13
+ from prodc.loader import LoaderError, load
14
+ from prodc.model import Answer, Need
15
+
16
+ _TOML = """
17
+ [prodc]
18
+ needs = ["needs/*.yaml"]
19
+
20
+ [proofs.x]
21
+ kind = "files"
22
+ """
23
+
24
+ # N-1 is settled (the files proof resolves) with a NO answer — a disproven hypothesis.
25
+ _NEEDS = """
26
+ needs:
27
+ - id: N-1
28
+ text: the Collatz conjecture is false
29
+ proofs: ["x:1"]
30
+ answer: no
31
+ - id: N-2
32
+ text: a question with no direction recorded
33
+ proofs: ["x:1"]
34
+ """
35
+
36
+
37
+ def _project(tmp_path: Path, toml: str = _TOML, needs: str = _NEEDS) -> Path:
38
+ (tmp_path / "needs").mkdir(parents=True, exist_ok=True)
39
+ (tmp_path / "prodc.toml").write_text(toml, encoding="utf-8")
40
+ (tmp_path / "needs" / "a.yaml").write_text(needs, encoding="utf-8")
41
+ (tmp_path / "1").write_text("ok\n", encoding="utf-8") # files adapter payload "1" exists
42
+ return tmp_path / "prodc.toml"
43
+
44
+
45
+ # --- model / loader ---------------------------------------------------------
46
+
47
+
48
+ def test_loader_parses_answer(tmp_path: Path) -> None:
49
+ by_id = {n.id: n for n in load(Config.load(_project(tmp_path))).needs}
50
+ assert by_id["N-1"].answer is Answer.NO
51
+ assert by_id["N-2"].answer is None # absent → None, a product need
52
+
53
+
54
+ def test_loader_accepts_every_direction(tmp_path: Path) -> None:
55
+ needs = """
56
+ needs:
57
+ - id: Y
58
+ text: settled yes
59
+ answer: yes
60
+ - id: N
61
+ text: settled no
62
+ answer: no
63
+ - id: I
64
+ text: cannot decide
65
+ answer: inconclusive
66
+ """
67
+ by_id = {n.id: n for n in load(Config.load(_project(tmp_path, needs=needs))).needs}
68
+ assert (by_id["Y"].answer, by_id["N"].answer, by_id["I"].answer) == (
69
+ Answer.YES,
70
+ Answer.NO,
71
+ Answer.INCONCLUSIVE,
72
+ )
73
+
74
+
75
+ def test_loader_errors_on_unknown_answer(tmp_path: Path) -> None:
76
+ bad = "needs:\n - id: B\n text: bad\n answer: maybe\n"
77
+ with pytest.raises(LoaderError, match="unknown answer 'maybe'"):
78
+ load(Config.load(_project(tmp_path, needs=bad)))
79
+
80
+
81
+ def test_answer_does_not_touch_coverage_rule() -> None:
82
+ # answer is a direction, not a coverage axis — a settled-no still counts
83
+ settled_no = Need(id="X", text="t", proofs=("x:1",), answer=Answer.NO)
84
+ assert settled_no.counts_toward_coverage is True
85
+ assert Need(id="X", text="t").answer is None # default, product need
86
+
87
+
88
+ # --- status (coverage + honesty warning) ------------------------------------
89
+
90
+
91
+ def test_settled_no_is_delivered_and_counts(
92
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
93
+ ) -> None:
94
+ from prodc.cli import main
95
+
96
+ main(["status", "--config", str(_project(tmp_path)), "--json"])
97
+ payload = json.loads(capsys.readouterr().out)
98
+ by_id = {n["id"]: n for n in payload["needs"]}
99
+ assert by_id["N-1"]["state"] == "delivered" # settled
100
+ assert by_id["N-1"]["answer"] == "no" # answered NO — a result
101
+ assert payload["summary"]["delivered"] == 2 # settled-no is delivered, not a gap
102
+ assert payload["summary"]["total"] == 2
103
+
104
+
105
+ def test_warns_when_answer_on_unsettled_question(
106
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
107
+ ) -> None:
108
+ from prodc.cli import main
109
+
110
+ # a HOLE (no proof) carrying an answer — the direction is unsupported
111
+ needs = "needs:\n - id: H\n text: unsettled\n answer: yes\n"
112
+ main(["status", "--config", str(_project(tmp_path, needs=needs)), "--json"])
113
+ payload = json.loads(capsys.readouterr().out)
114
+ warnings = payload["needs"][0]["warnings"]
115
+ assert any("declared on an unsettled question" in w for w in warnings)
116
+
117
+
118
+ def test_no_warning_when_answer_on_settled_question(
119
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
120
+ ) -> None:
121
+ from prodc.cli import main
122
+
123
+ main(["status", "--config", str(_project(tmp_path)), "--json"])
124
+ payload = json.loads(capsys.readouterr().out)
125
+ n1 = next(n for n in payload["needs"] if n["id"] == "N-1")
126
+ assert not any("unsettled question" in w for w in n1["warnings"]) # DELIVERED + no is honest
127
+
128
+
129
+ # --- render (show) ----------------------------------------------------------
130
+
131
+
132
+ def test_show_renders_answer(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
133
+ from prodc.cli import main
134
+
135
+ main(["show", "N-1", "--config", str(_project(tmp_path))])
136
+ assert "answer: no" in capsys.readouterr().out
137
+
138
+
139
+ # --- writer (round-trips through the one-need splice with no writer change) --
140
+
141
+
142
+ def test_answer_survives_a_splice_edit(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
143
+ from prodc.cli import main
144
+
145
+ cfg = _project(tmp_path)
146
+ main(["set", "N-1", "importance=3", "--config", str(cfg)]) # edit a different field
147
+ capsys.readouterr() # drop the diff
148
+ by_id = {n.id: n for n in load(Config.load(cfg)).needs}
149
+ assert by_id["N-1"].answer is Answer.NO # the authored answer is still there
150
+ assert by_id["N-1"].importance == 3
151
+ # on disk it stays the research word, never re-dumped as the YAML bool `false`
152
+ text = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
153
+ assert "answer: 'no'" in text
154
+ assert "answer: false" not in text
@@ -0,0 +1,39 @@
1
+ """The `citation` proof adapter (research-board step 5): a cited published result as a declared
2
+ DELIVERED. Distinct from `accept` on purpose — `accept` records who signed off, a citation records
3
+ what external result settles the question (the `ref` a reader follows to check it)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from prodc.adapters import CitationAdapter
10
+ from prodc.config import Config
11
+ from prodc.model import ProofState
12
+
13
+
14
+ def test_citation_is_declared_delivered() -> None:
15
+ r = CitationAdapter().resolve("Smith2019@Smith@2019")
16
+ assert r.state is ProofState.DELIVERED
17
+ assert r.declared is True # the `*` marker — declared, not checked by prodc
18
+ assert r.detail == "cites Smith2019 by Smith (2019)"
19
+
20
+
21
+ def test_citation_ref_only() -> None:
22
+ r = CitationAdapter().resolve("arXiv:2401.00001")
23
+ assert r.state is ProofState.DELIVERED
24
+ assert r.detail == "cites arXiv:2401.00001" # author/year optional
25
+
26
+
27
+ def test_citation_never_broken_even_when_empty() -> None:
28
+ # it is an assertion, like a tag — it never fails to resolve
29
+ assert CitationAdapter().resolve("").state is ProofState.DELIVERED
30
+
31
+
32
+ def test_config_builds_citation_adapter(tmp_path: Path) -> None:
33
+ (tmp_path / "needs").mkdir()
34
+ (tmp_path / "prodc.toml").write_text(
35
+ '[prodc]\nneeds = ["needs/*.yaml"]\n\n[proofs.paper]\nkind = "citation"\n',
36
+ encoding="utf-8",
37
+ )
38
+ cfg = Config.load(tmp_path / "prodc.toml")
39
+ assert isinstance(cfg.adapters["paper"], CitationAdapter)
@@ -0,0 +1,66 @@
1
+ """The claim-gated text lint — what replaced the product/research profile fork. The EARS /
2
+ solution-verb lint fires only on a need that has NOT stated its `claim` (the warrant). A need that
3
+ articulates its claim has already done the thinking the lint prods toward, so it is silenced — one
4
+ board serves a product PM and a research lab head with no mode. Source hygiene always runs."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from prodc.config import Config
11
+ from prodc.loader import load
12
+ from prodc.status import resolve
13
+
14
+
15
+ def _warnings(tmp_path: Path, needs: str) -> list[str]:
16
+ (tmp_path / "needs").mkdir(parents=True, exist_ok=True)
17
+ (tmp_path / "prodc.toml").write_text('[prodc]\nneeds = ["needs/*.yaml"]\n', encoding="utf-8")
18
+ (tmp_path / "needs" / "a.yaml").write_text(needs, encoding="utf-8")
19
+ cfg = Config.load(tmp_path / "prodc.toml")
20
+ results = resolve(list(load(cfg).needs), cfg.adapters, cfg.source_adapters)
21
+ return [w for r in results for w in r.warnings]
22
+
23
+
24
+ def test_claimless_need_is_linted(tmp_path: Path) -> None:
25
+ # a claim with no outcome phrasing and no `claim` field — EARS flags it
26
+ warnings = _warnings(tmp_path, "needs:\n - id: Q\n text: Prove that pi is irrational\n")
27
+ assert any("EARS" in w for w in warnings)
28
+
29
+
30
+ def test_stating_the_claim_silences_the_lint(tmp_path: Path) -> None:
31
+ # the same text, now with the warrant articulated — the lint is silenced, no mode needed
32
+ needs = (
33
+ "needs:\n"
34
+ " - id: Q\n"
35
+ " text: Prove that pi is irrational\n"
36
+ " claim: pi has no rational representation p/q\n"
37
+ )
38
+ warnings = _warnings(tmp_path, needs)
39
+ assert not any("EARS" in w for w in warnings)
40
+
41
+
42
+ def test_claim_silences_solution_verb_too(tmp_path: Path) -> None:
43
+ verb = "needs:\n - id: Q\n text: implement the sieve\n"
44
+ assert any("solution in disguise" in w for w in _warnings(tmp_path, verb))
45
+ with_claim = verb + " claim: primes below N are enumerated correctly\n"
46
+ assert not any("solution in disguise" in w for w in _warnings(tmp_path, with_claim))
47
+
48
+
49
+ def test_product_need_without_claim_still_linted(tmp_path: Path) -> None:
50
+ # the common product case is unchanged: no claim set → the EARS nudge still fires
51
+ needs = "needs:\n - id: N\n text: a vague capability with no outcome word\n"
52
+ assert any("EARS" in w for w in _warnings(tmp_path, needs))
53
+
54
+
55
+ def test_source_hygiene_runs_regardless_of_claim(tmp_path: Path) -> None:
56
+ # a primary source with no named person is flagged whether or not a claim is stated
57
+ needs = """
58
+ needs:
59
+ - id: Q
60
+ text: a settled claim
61
+ claim: the thing holds
62
+ sources:
63
+ - kind: primary
64
+ date: 2026-10-11
65
+ """
66
+ assert any("primary source without a named person" in w for w in _warnings(tmp_path, needs))
@@ -0,0 +1,100 @@
1
+ """The `evalgate` proof adapter (research-board step 4, #75): a scored sibling of the external
2
+ (OutcomeAdapter) proof. It reads a numeric eval score from a results file and gates it against a
3
+ threshold carried in the locator (`accuracy>=0.9`), so one adapter serves many metrics and cutoffs.
4
+ Records-only — prodc reads the recorded score, never runs the eval."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ from prodc.adapters import EvalGateAdapter
14
+ from prodc.config import Config, ConfigError
15
+ from prodc.model import ProofState
16
+
17
+
18
+ def _adapter(tmp_path: Path, results: object, name: str = "eval.json") -> EvalGateAdapter:
19
+ (tmp_path / name).write_text(json.dumps(results), encoding="utf-8")
20
+ return EvalGateAdapter(root=tmp_path, results=name)
21
+
22
+
23
+ def test_delivered_when_score_meets_gate(tmp_path: Path) -> None:
24
+ a = _adapter(tmp_path, {"accuracy": {"score": 0.82}})
25
+ r = a.resolve("accuracy>=0.8")
26
+ assert r.state is ProofState.DELIVERED
27
+ assert "0.82" in r.detail and "pass" in r.detail
28
+
29
+
30
+ def test_pending_when_score_below_gate(tmp_path: Path) -> None:
31
+ a = _adapter(tmp_path, {"accuracy": {"score": 0.72}})
32
+ r = a.resolve("accuracy>=0.8")
33
+ assert r.state is ProofState.PENDING # recorded, but not green
34
+ assert "below gate" in r.detail
35
+
36
+
37
+ def test_bare_number_is_accepted_as_a_score(tmp_path: Path) -> None:
38
+ a = _adapter(tmp_path, {"f1": 0.9})
39
+ assert a.resolve("f1>=0.9").state is ProofState.DELIVERED # >= is inclusive
40
+
41
+
42
+ def test_less_than_gate_for_a_loss_metric(tmp_path: Path) -> None:
43
+ a = _adapter(tmp_path, {"val_loss": {"score": 0.15}})
44
+ assert a.resolve("val_loss<=0.2").state is ProofState.DELIVERED
45
+ assert a.resolve("val_loss<=0.1").state is ProofState.PENDING
46
+
47
+
48
+ def test_strict_operators(tmp_path: Path) -> None:
49
+ a = _adapter(tmp_path, {"m": 0.5})
50
+ assert a.resolve("m>0.5").state is ProofState.PENDING # strict: 0.5 is not > 0.5
51
+ assert a.resolve("m<0.6").state is ProofState.DELIVERED
52
+
53
+
54
+ def test_pending_when_metric_absent_from_present_file(tmp_path: Path) -> None:
55
+ a = _adapter(tmp_path, {"accuracy": {"score": 0.82}})
56
+ r = a.resolve("recall>=0.8")
57
+ assert r.state is ProofState.PENDING # file read, metric not recorded yet
58
+ assert "not in results" in r.detail
59
+
60
+
61
+ def test_broken_when_results_file_absent(tmp_path: Path) -> None:
62
+ a = EvalGateAdapter(root=tmp_path, results="missing.json") # never written
63
+ r = a.resolve("accuracy>=0.8")
64
+ assert r.state is ProofState.BROKEN
65
+ assert "no results file" in r.detail
66
+
67
+
68
+ def test_broken_on_malformed_locator(tmp_path: Path) -> None:
69
+ a = _adapter(tmp_path, {"accuracy": {"score": 0.82}})
70
+ assert a.resolve("accuracy").state is ProofState.BROKEN # no operator
71
+ assert a.resolve("accuracy>=high").state is ProofState.BROKEN # threshold not a number
72
+
73
+
74
+ def test_bool_is_not_a_score(tmp_path: Path) -> None:
75
+ # a pass/fail bool belongs to the external adapter, not the gate — treated as absent
76
+ a = _adapter(tmp_path, {"flag": True})
77
+ assert a.resolve("flag>=0.5").state is ProofState.PENDING
78
+
79
+
80
+ # --- config wiring ----------------------------------------------------------
81
+
82
+
83
+ def test_config_builds_evalgate_adapter(tmp_path: Path) -> None:
84
+ (tmp_path / "needs").mkdir()
85
+ (tmp_path / "prodc.toml").write_text(
86
+ '[prodc]\nneeds = ["needs/*.yaml"]\n\n'
87
+ '[proofs.eval]\nkind = "evalgate"\nresults = "eval.json"\n',
88
+ encoding="utf-8",
89
+ )
90
+ cfg = Config.load(tmp_path / "prodc.toml")
91
+ assert isinstance(cfg.adapters["eval"], EvalGateAdapter)
92
+
93
+
94
+ def test_config_evalgate_requires_results(tmp_path: Path) -> None:
95
+ (tmp_path / "prodc.toml").write_text(
96
+ '[prodc]\nneeds = ["needs/*.yaml"]\n\n[proofs.eval]\nkind = "evalgate"\n',
97
+ encoding="utf-8",
98
+ )
99
+ with pytest.raises(ConfigError, match="evalgate needs results"):
100
+ Config.load(tmp_path / "prodc.toml")
@@ -0,0 +1,175 @@
1
+ """The `need:` proof adapter (research-board step 2): a need→need edge for the question→question
2
+ DAG. `need:A` delivers iff A is settled; `need:A@yes` iff A is settled *and* answered that way.
3
+ It rides the proofs tuple and reuses the resolver's cycle detection — no new field, no new edge."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from prodc.config import Config
10
+ from prodc.loader import load
11
+ from prodc.model import Answer, NeedState
12
+ from prodc.status import resolve
13
+
14
+ _TOML = """
15
+ [prodc]
16
+ needs = ["needs/*.yaml"]
17
+
18
+ [proofs.x]
19
+ kind = "files"
20
+ """
21
+
22
+
23
+ def _resolve(tmp_path: Path, needs: str) -> dict[str, NeedState]:
24
+ (tmp_path / "needs").mkdir(parents=True, exist_ok=True)
25
+ (tmp_path / "prodc.toml").write_text(_TOML, encoding="utf-8")
26
+ (tmp_path / "needs" / "a.yaml").write_text(needs, encoding="utf-8")
27
+ (tmp_path / "1").write_text("ok\n", encoding="utf-8") # the files proof `x:1` exists → settled
28
+ cfg = Config.load(tmp_path / "prodc.toml")
29
+ model = load(cfg)
30
+ results = resolve(list(model.needs), cfg.adapters, cfg.source_adapters)
31
+ return {r.need.id: r.state for r in results}
32
+
33
+
34
+ def test_needref_delivers_when_referenced_is_settled(tmp_path: Path) -> None:
35
+ states = _resolve(
36
+ tmp_path,
37
+ """
38
+ needs:
39
+ - id: A
40
+ text: lemma A is proven
41
+ proofs: ["x:1"]
42
+ - id: B
43
+ text: theorem B builds on A
44
+ proofs: ["need:A"]
45
+ """,
46
+ )
47
+ assert states["A"] is NeedState.DELIVERED
48
+ assert states["B"] is NeedState.DELIVERED # A is settled → B's needref delivers
49
+
50
+
51
+ def test_needref_pends_when_referenced_not_settled(tmp_path: Path) -> None:
52
+ states = _resolve(
53
+ tmp_path,
54
+ """
55
+ needs:
56
+ - id: A
57
+ text: lemma A, no proof yet
58
+ - id: B
59
+ text: theorem B builds on A
60
+ proofs: ["need:A"]
61
+ """,
62
+ )
63
+ assert states["A"] is NeedState.HOLE # no proof
64
+ assert states["B"] is NeedState.PENDING # cannot deliver until A settles
65
+
66
+
67
+ def test_needref_broken_on_unknown_id(tmp_path: Path) -> None:
68
+ states = _resolve(
69
+ tmp_path,
70
+ """
71
+ needs:
72
+ - id: B
73
+ text: depends on a need that does not exist
74
+ proofs: ["need:GHOST"]
75
+ """,
76
+ )
77
+ assert states["B"] is NeedState.BROKEN
78
+
79
+
80
+ def test_needref_directional_matches_authored_answer(tmp_path: Path) -> None:
81
+ states = _resolve(
82
+ tmp_path,
83
+ """
84
+ needs:
85
+ - id: A
86
+ text: the counterexample search settled it
87
+ proofs: ["x:1"]
88
+ answer: no
89
+ - id: B
90
+ text: needs A specifically answered no
91
+ proofs: ["need:A@no"]
92
+ - id: C
93
+ text: needs A answered yes
94
+ proofs: ["need:A@yes"]
95
+ """,
96
+ )
97
+ assert states["A"] is NeedState.DELIVERED
98
+ assert states["B"] is NeedState.DELIVERED # A settled AND answered no
99
+ assert states["C"] is NeedState.PENDING # A settled, but answered no, not yes
100
+
101
+
102
+ def test_needref_directional_pends_when_no_answer_recorded(tmp_path: Path) -> None:
103
+ states = _resolve(
104
+ tmp_path,
105
+ """
106
+ needs:
107
+ - id: A
108
+ text: settled but no direction recorded
109
+ proofs: ["x:1"]
110
+ - id: B
111
+ text: wants A answered yes
112
+ proofs: ["need:A@yes"]
113
+ """,
114
+ )
115
+ assert states["A"] is NeedState.DELIVERED
116
+ assert states["B"] is NeedState.PENDING # settled, but no answer to match the direction
117
+
118
+
119
+ def test_needref_malformed_direction_is_broken(tmp_path: Path) -> None:
120
+ states = _resolve(
121
+ tmp_path,
122
+ """
123
+ needs:
124
+ - id: A
125
+ text: settled
126
+ proofs: ["x:1"]
127
+ - id: B
128
+ text: bad direction token
129
+ proofs: ["need:A@maybe"]
130
+ """,
131
+ )
132
+ assert states["B"] is NeedState.BROKEN
133
+
134
+
135
+ def test_needref_cycle_is_broken_not_infinite(tmp_path: Path) -> None:
136
+ states = _resolve(
137
+ tmp_path,
138
+ """
139
+ needs:
140
+ - id: A
141
+ text: A depends on B
142
+ proofs: ["need:B"]
143
+ - id: B
144
+ text: B depends on A
145
+ proofs: ["need:A"]
146
+ """,
147
+ )
148
+ assert states["A"] is NeedState.BROKEN
149
+ assert states["B"] is NeedState.BROKEN # the cycle is caught, resolution terminates
150
+
151
+
152
+ def test_needref_b_does_not_inherit_a_answer(tmp_path: Path) -> None:
153
+ # B depends on A (answered no) but B is its own question with its own answer
154
+ by_id = {}
155
+ (tmp_path / "needs").mkdir(parents=True, exist_ok=True)
156
+ (tmp_path / "prodc.toml").write_text(_TOML, encoding="utf-8")
157
+ (tmp_path / "1").write_text("ok\n", encoding="utf-8")
158
+ (tmp_path / "needs" / "a.yaml").write_text(
159
+ """
160
+ needs:
161
+ - id: A
162
+ text: A answered no
163
+ proofs: ["x:1"]
164
+ answer: no
165
+ - id: B
166
+ text: B depends on A, answered yes itself
167
+ proofs: ["need:A"]
168
+ answer: yes
169
+ """,
170
+ encoding="utf-8",
171
+ )
172
+ cfg = Config.load(tmp_path / "prodc.toml")
173
+ by_id = {n.id: n for n in load(cfg).needs}
174
+ assert by_id["A"].answer is Answer.NO
175
+ assert by_id["B"].answer is Answer.YES # B keeps its own answer, not A's
@@ -1,24 +0,0 @@
1
- who: jordan
2
- label: "design partner"
3
- needs:
4
- - id: N-1
5
- text: "see which orders are past due at a glance"
6
- must: true
7
- sources:
8
- - kind: primary
9
- who: Jordan
10
- date: 2026-09-20
11
- quote: "I lose the past-due ones in the list"
12
- proofs:
13
- - "web:orders.feature#Past due orders are highlighted"
14
-
15
- - id: N-2
16
- text: "export the day's delivery run sheet"
17
- sources:
18
- - kind: secondary
19
- locator: issues.md#N-2
20
- date: 2026-09-21
21
- proofs: [] # sourced HOLE: the need is evidenced, but no test proves it yet
22
-
23
- - id: N-3
24
- text: "implement a settings page" # opinion (no source) + solution-in-disguise warning
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes