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.
- {prodc-0.3.66 → prodc-0.3.72}/PKG-INFO +1 -1
- prodc-0.3.72/examples/quickstart/needs/jordan.yaml +40 -0
- {prodc-0.3.66 → prodc-0.3.72}/examples/quickstart/prodc.toml +5 -1
- {prodc-0.3.66 → prodc-0.3.72}/pyproject.toml +1 -1
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/adapters.py +111 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/board.py +2 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/render.py +2 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/config.py +9 -1
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/loader.py +26 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/model.py +23 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/status.py +101 -6
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/writer.py +19 -0
- prodc-0.3.72/tests/test_answer.py +154 -0
- prodc-0.3.72/tests/test_citation.py +39 -0
- prodc-0.3.72/tests/test_claim_gating.py +66 -0
- prodc-0.3.72/tests/test_evalgate.py +100 -0
- prodc-0.3.72/tests/test_needref.py +175 -0
- prodc-0.3.66/examples/quickstart/needs/jordan.yaml +0 -24
- {prodc-0.3.66 → prodc-0.3.72}/.gitignore +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/CHANGELOG.md +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/LICENSE +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/README.md +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/__init__.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/agent.md +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/app.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/commands.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/dedup.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/py.typed +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/report.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/src/prodc/report_html.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_adapters.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_callbacks.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_cli.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_command_hook.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_dedup.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_description.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_drafts.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_importance.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_loader.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_models.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_report.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_report_html.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_roles.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_sources.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_staleness.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_status.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_suggest.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_variants.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_who_variants.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_withdrawn.py +0 -0
- {prodc-0.3.66 → prodc-0.3.72}/tests/test_writer.py +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 = [
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|