From 62ed473642b528cad6ea2bb47ae34227db336e51 Mon Sep 17 00:00:00 2001
From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com>
Date: Mon, 5 Oct 2026 05:13:01 -0400
Subject: [PATCH 01/38] Make the open-statement policy configurable (spec
A1-A5)
Add an `open_statements: allowed|forbidden` policy to roadmap/README.md.
Absent or forbidden keeps the strict policy, where CI rejects every sorry;
the key on any other article, or any other value, is a validation issue.
Derived status now records can_state, can_prove, waiting_on and assumes.
Under the strict policy a statement also waits for its proof prerequisites
to be proved, matching what `work list` already enforced. Under the open
policy a statement waits only for its statement prerequisites, a proof for
every prerequisite to be stated, and a proof that rests on an open
statement gets the new violet `conditional` state ("conditionally proved"),
never fully proved.
The legend, the Next up card and article pages follow: conditional articles
carry an Assumes row naming the open statements they rest on. The runtime
projection takes readiness from the derived status and serializes assumes,
waiting_on and the policy. The work frontier reads blockers from the
derived status and names the policy and each item's assumptions in text
and JSON. The new `autoform work assumptions [target] [--json]` command
emits the autoform-assumptions/v1 contract that CI audits the Lean build
against. Strict-policy work text is unchanged.
---
autoform_cli/__main__.py | 43 ++++-
autoform_cli/graph.py | 21 ++-
autoform_cli/mermaid.py | 5 +-
autoform_cli/render.py | 41 +++--
autoform_cli/runtime.py | 21 ++-
autoform_cli/status.py | 72 +++++++-
autoform_cli/work.py | 120 ++++++++++---
tests/test_graph.py | 51 ++++++
tests/test_render.py | 55 ++++++
tests/test_runtime.py | 58 +++++++
tests/test_status.py | 164 +++++++++++++++++-
tests/test_visualization.py | 21 +++
tests/test_work.py | 337 +++++++++++++++++++++++++++++++++++-
13 files changed, 955 insertions(+), 54 deletions(-)
diff --git a/autoform_cli/__main__.py b/autoform_cli/__main__.py
index 45b4510e..722ba1aa 100644
--- a/autoform_cli/__main__.py
+++ b/autoform_cli/__main__.py
@@ -34,7 +34,7 @@
write_packets,
write_skeleton_report,
)
-from .work import WORK_SCHEMA, WorkError, list_ready_work, work_context
+from .work import WORK_SCHEMA, WorkError, assumption_contract, list_ready_work, work_context
def main(argv: Sequence[str] | None = None) -> int:
@@ -132,6 +132,13 @@ def main(argv: Sequence[str] | None = None) -> int:
work_context_parser.add_argument(
"--json", action="store_true", help="write stable machine-readable output"
)
+ work_assumptions = work_subparsers.add_parser(
+ "assumptions", help="list open statements and the open statements each stated article rests on"
+ )
+ work_assumptions.add_argument(
+ "target", nargs="?", default=".", help="project root or blueprint directory"
+ )
+ work_assumptions.add_argument("--json", action="store_true", help="write stable machine-readable output")
claim = subparsers.add_parser("claim", help="coordinate temporary node ownership through Git refs")
claim_subparsers = claim.add_subparsers(dest="claim_command", required=True)
for operation in ("acquire", "renew", "release"):
@@ -429,6 +436,8 @@ def _project(args: argparse.Namespace) -> int:
def _work(args: argparse.Namespace) -> int:
+ if args.work_command == "assumptions":
+ return _work_assumptions(args)
# Only loading the roadmap can fail on the project's paths; printing the
# result stays outside, so an output error is not reported as one.
try:
@@ -455,12 +464,16 @@ def _work(args: argparse.Namespace) -> int:
if args.json:
print(frontier.to_json())
return 0
+ if frontier.open_statements:
+ print("Open statements: allowed (a statement may land with a sorry proof)")
if not frontier.items:
print("No ready formalization work.")
return 0
for item in frontier.items:
durable = f" [{item.article_id}]" if item.article_id else ""
print(_human_text(f"{item.phase}: {item.node_id}{durable} - {item.title}"))
+ if item.assumes:
+ print(_human_text(" assumes: " + ", ".join(item.assumes)))
return 0
if args.json:
@@ -485,6 +498,10 @@ def _work(args: argparse.Namespace) -> int:
print(_human_text(f"{item.title} ({item.node_id})"))
print(f"State: {item.state}")
print(f"Phase: {phase}")
+ if item.open_statements:
+ print("Open statements: allowed")
+ if item.assumes:
+ print(_human_text("Assumes: " + ", ".join(item.assumes)))
print(_human_text(f"Claim target: {item.claim_target}"))
if item.blockers:
print(_human_text("Blocked by: " + ", ".join(item.blockers)))
@@ -501,6 +518,30 @@ def _work(args: argparse.Namespace) -> int:
return 0
+def _work_assumptions(args: argparse.Namespace) -> int:
+ # As in `_work`, only loading the roadmap is reported as a path error.
+ try:
+ contract = assumption_contract(args.target)
+ except (GraphValidationError, RuntimeProjectionError) as error:
+ for issue in error.issues:
+ print(f"error: {_human_text(issue)}", file=sys.stderr)
+ return 2
+ except (OSError, RuntimeError, ValueError):
+ print("error: project or blueprint path cannot be read", file=sys.stderr)
+ return 2
+
+ if args.json:
+ print(contract.to_json())
+ return 0
+ print(_human_text(f"Open statements: {'allowed' if contract.open_statements else 'forbidden'}"))
+ for article in contract.articles:
+ if article.open:
+ print(_human_text(f"open: {article.id} ({', '.join(article.declarations)})"))
+ if article.assumes:
+ print(_human_text(f"conditional: {article.id} assumes {', '.join(article.assumes)}"))
+ return 0
+
+
def _print_project_inspection(result) -> None:
if result.project_root is not None:
print(f"Project root: {_human_text(result.project_root)}")
diff --git a/autoform_cli/graph.py b/autoform_cli/graph.py
index 1982dd73..916507a4 100644
--- a/autoform_cli/graph.py
+++ b/autoform_cli/graph.py
@@ -35,6 +35,7 @@
"not_ready",
"origin",
"discussion",
+ "open_statements",
}
)
_FORMALIZED = "formalized"
@@ -102,6 +103,10 @@ class Graph:
blueprint_dir: Path
nodes: dict[str, Node]
+ #: ``open_statements: allowed`` in ``roadmap/README.md``: a theorem's
+ #: statement may land with a ``sorry`` proof. Absent or ``forbidden`` keeps
+ #: the strict policy, where CI rejects every ``sorry``.
+ open_statements: bool = False
@property
def edge_count(self) -> int:
@@ -146,6 +151,8 @@ def load_graph(blueprint_dir: str | Path) -> Graph:
issues.extend(discovery_issues)
article_ids: dict[str, str] = {}
source_hashes = {source.id: source.source_sha256 for source in sources}
+ policy_page = (blueprint / "roadmap" / "README.md").resolve()
+ open_statements = False
for source in sources:
canonical = source.path.resolve()
@@ -173,6 +180,14 @@ def load_graph(blueprint_dir: str | Path) -> Graph:
)
else:
article_ids[article_id] = node.id
+ policy = node.metadata.get("open_statements")
+ if policy is not None:
+ if canonical != policy_page:
+ issues.append(
+ f"{node.id}: open_statements is a project policy; set it only in roadmap/README.md"
+ )
+ else:
+ open_statements = policy == "allowed"
parsed.append(node)
if issues:
@@ -232,7 +247,7 @@ def resolve(targets: tuple[str, ...], node: _ParsedNode = parsed_node) -> list[s
issues.extend(_find_rollup_cycles(nodes))
if issues:
raise GraphValidationError(issues)
- return Graph(blueprint_dir=blueprint, nodes=nodes)
+ return Graph(blueprint_dir=blueprint, nodes=nodes, open_statements=open_statements)
def _discover_nodes(blueprint: Path) -> tuple[list[_NodeSource], list[str]]:
@@ -480,6 +495,10 @@ def _normalize_value(node_id: str, line_number: int, key: str, value: str) -> tu
if folded not in {"cited", "bridged", "background"}:
return value, f"{location}: 'origin' accepts cited, bridged, or background"
return folded, None
+ if key == "open_statements":
+ if folded not in {"allowed", "forbidden"}:
+ return value, f"{location}: 'open_statements' accepts allowed or forbidden"
+ return folded, None
return value, None
diff --git a/autoform_cli/mermaid.py b/autoform_cli/mermaid.py
index a9f92ef2..93d051a4 100644
--- a/autoform_cli/mermaid.py
+++ b/autoform_cli/mermaid.py
@@ -265,9 +265,10 @@ def render_legend_tip(statuses: dict[str, NodeStatus]) -> str:
"fully_proved": "Proved, and every prerequisite is fully proved too.",
"proved": "Proof compiles, but something it rests on is not finished.",
"defined": "Definition is written in Lean.",
- "can_prove": "Statement is in Lean and every prerequisite is proved — ready to work.",
+ "conditional": "Proof compiles, but rests on an open statement whose Lean proof is still sorry.",
+ "can_prove": "Statement is in Lean and nothing it needs is blocked, so the proof can start.",
"stated": "Statement is in Lean; the proof is not.",
- "can_state": "Prerequisites are stated, so this can be written down.",
+ "can_state": "Nothing it needs is blocked, so the statement can be written in Lean.",
"not_ready": "Needs more blueprint work before it can be attempted.",
"planned": "Described in the blueprint only.",
}
diff --git a/autoform_cli/render.py b/autoform_cli/render.py
index bd514428..7891b9a4 100644
--- a/autoform_cli/render.py
+++ b/autoform_cli/render.py
@@ -856,9 +856,9 @@ def _next_target(
f'{title}' if statement else title
)
why = (
- "Every prerequisite is proved, so the proof can be written now."
+ "Its prerequisites are ready, so the proof can be written now."
if node_status.key == "can_prove"
- else "Every prerequisite is stated, so this can be written down."
+ else "Its prerequisites are ready, so the statement can be written down."
)
actions = [f'Dependencies']
if chapter_page is not None:
@@ -1702,6 +1702,13 @@ def _render_environment(
context_link = _graph_context_link(node, page=page, destination=destination)
source_link = _vault_source_link(node, repo_root=repo_root, linker=linker)
meta_rows = implementation_rows
+ if node_status.key == "conditional":
+ # A conditional proof must never read as finished, so the open
+ # statements it rests on are named on the statement itself.
+ assumed = _node_references(
+ node_status.assumes, graph=graph, statuses=statuses, numbers=numbers, links=links
+ )
+ meta_rows.append(("Assumes", f"{assumed} (open statements whose Lean proofs are still sorry)"))
if node.discussion:
meta_rows.append(("Discussion", _discussion_link(node.discussion, linker)))
meta = _render_rows(meta_rows, css_class="bp-meta")
@@ -1849,15 +1856,7 @@ def _dependency_disclosure(
rows: list[tuple[str, str]] = []
def references(node_ids: list[str] | tuple[str, ...]) -> str:
- rendered = []
- for other_id in node_ids:
- other = graph.nodes[other_id]
- label = html.escape(f"{numbers[other_id]} ({other.title})")
- rendered.append(
- f'{label}'
- )
- return " · ".join(rendered)
+ return _node_references(node_ids, graph=graph, statuses=statuses, numbers=numbers, links=links)
if node.statement_dependencies:
rows.append(("Statement uses", references(node.statement_dependencies)))
@@ -1873,6 +1872,26 @@ def references(node_ids: list[str] | tuple[str, ...]) -> str:
return f'Dependencies
{body} '
+def _node_references(
+ node_ids: list[str] | tuple[str, ...],
+ *,
+ graph: Graph,
+ statuses: dict[str, status.NodeStatus],
+ numbers: dict[str, str],
+ links: dict[str, str],
+) -> str:
+ """Link each node by number and title, coloured by its derived state."""
+ rendered = []
+ for other_id in node_ids:
+ other = graph.nodes[other_id]
+ label = html.escape(f"{numbers[other_id]} ({other.title})")
+ rendered.append(
+ f'{label}'
+ )
+ return " · ".join(rendered)
+
+
def _render_rows(rows: list[tuple[str, str]], *, css_class: str) -> str:
if not rows:
return ""
diff --git a/autoform_cli/runtime.py b/autoform_cli/runtime.py
index 16559104..49db6b8f 100644
--- a/autoform_cli/runtime.py
+++ b/autoform_cli/runtime.py
@@ -64,9 +64,12 @@ class RuntimeStatus:
proved: bool
fully_proved: bool
defined: bool
+ assumes: tuple[str, ...]
+ waiting_on: tuple[str, ...]
- def as_dict(self) -> dict[str, bool | str]:
+ def as_dict(self) -> dict[str, bool | str | list[str]]:
return {
+ "assumes": list(self.assumes),
"can_prove": self.can_prove,
"can_state": self.can_state,
"defined": self.defined,
@@ -74,6 +77,7 @@ def as_dict(self) -> dict[str, bool | str]:
"proved": self.proved,
"state": self.state,
"stated": self.stated,
+ "waiting_on": list(self.waiting_on),
}
@@ -157,6 +161,7 @@ class RuntimeGraph:
dispatchable_count: int
dependency_count: int
maximum_depth: int
+ open_statements: bool = False
def get(self, node_id: str) -> RuntimeNode | None:
"""Return a node without exposing mutable lookup state."""
@@ -175,6 +180,7 @@ def as_dict(self) -> dict[str, object]:
"formalizable_count": self.formalizable_count,
"maximum_depth": self.maximum_depth,
"nodes": [node.as_dict() for node in self.nodes],
+ "open_statements": self.open_statements,
"schema": self.schema,
"source_revision": self.source_revision,
}
@@ -317,12 +323,6 @@ def build_runtime_graph(
for node_id in sorted(graph.nodes):
node = graph.nodes[node_id]
node_status = statuses[node_id]
- can_state = all(statuses[dependency].stated for dependency in node.statement_dependencies)
- can_prove = (
- node_status.stated
- and can_state
- and all(statuses[dependency].proved for dependency in node.proof_dependencies)
- )
lean_targets: list[RuntimeLeanTarget] = []
for name in declaration_names(node.lean or ""):
declaration = lean_index.find(name) if lean_index is not None else None
@@ -352,12 +352,14 @@ def build_runtime_graph(
),
status=RuntimeStatus(
state=node_status.key,
- can_state=can_state,
- can_prove=can_prove,
+ can_state=node_status.can_state,
+ can_prove=node_status.can_prove,
stated=node_status.stated,
proved=node_status.proved,
fully_proved=node_status.fully_proved,
defined=is_definition(node) and node_status.stated,
+ assumes=node_status.assumes,
+ waiting_on=node_status.waiting_on,
),
origin=node.origin,
source_targets=node.sources,
@@ -380,6 +382,7 @@ def build_runtime_graph(
dispatchable_count=sum(node.dispatchable for node in nodes),
dependency_count=sum(len(node.dependencies) for node in nodes),
maximum_depth=max((node.depth for node in nodes), default=0),
+ open_statements=graph.open_statements,
)
_validate_runtime(runtime)
return runtime
diff --git a/autoform_cli/status.py b/autoform_cli/status.py
index 821950e4..032076d5 100644
--- a/autoform_cli/status.py
+++ b/autoform_cli/status.py
@@ -65,7 +65,10 @@ class State:
#: semantic set -- #31A24C green, #0064E0 blue, #F7B928 amber, #B0B3B8 grey --
#: so that finished, actionable, blocked and untouched read at a glance without
#: anyone learning a legend. Green tracks proof progress, blue marks what a
-#: contributor can pick up now, amber marks what nothing can start on.
+#: contributor can pick up now, amber marks what nothing can start on. Violet
+#: marks a proof that compiles but rests on an open statement (a theorem whose
+#: Lean proof is still ``sorry``): filled because the work is done, never green
+#: so that it cannot be mistaken for a sorry-free proof.
#:
#: Dark is not light dimmed: on #18191A a saturated fill closes up, so dark
#: states are near-black panels with a bright stroke and brighter label.
@@ -78,6 +81,8 @@ class State:
"#122A1B", "#31A24C", "#6BD97F"),
State("defined", "defined", "#C3E9CE", "#22773A", "#0B2415",
"#122A1B", "#2B8F44", "#6BD97F"),
+ State("conditional", "conditionally proved", "#E9DFFC", "#6B3FCF", "#2E1065",
+ "#231A36", "#9F7AEA", "#D6C8FA"),
State("can_prove", "ready to prove", "#FFFFFF", "#0064E0", "#0064E0",
"#101F33", "#2D88FF", "#7FB8FF"),
State("stated", "statement formalized", "#FFFFFF", "#31A24C", "#22773A",
@@ -95,13 +100,23 @@ class State:
@dataclass(frozen=True, slots=True)
class NodeStatus:
- """The derived progress of a single node."""
+ """The derived progress of a single node.
+
+ ``waiting_on`` names the prerequisites that keep an unproved node from its
+ next phase, in authored order. ``assumes`` names the open statements (stated,
+ not proved) a proof of this node rests on; it is empty unless the project
+ allows open statements.
+ """
node_id: str
state: State
stated: bool
proved: bool
fully_proved: bool
+ can_state: bool = False
+ can_prove: bool = False
+ assumes: tuple[str, ...] = ()
+ waiting_on: tuple[str, ...] = ()
@property
def key(self) -> str:
@@ -121,8 +136,22 @@ def is_definition(node: Node) -> bool:
def derive(graph: Graph) -> dict[str, NodeStatus]:
- """Return the derived status of every node in *graph*, keyed by node id."""
+ """Return the derived status of every node in *graph*, keyed by node id.
+
+ Readiness follows the project's policy. Under the default strict policy CI
+ rejects every ``sorry``, so a theorem's statement lands only with its proof:
+ both phases wait for the statement prerequisites to be stated and the proof
+ prerequisites to be proved. When ``roadmap/README.md`` sets
+ ``open_statements: allowed``, a statement may land with a ``sorry`` proof, so
+ a statement waits only for its statement prerequisites and a proof for every
+ prerequisite to be stated.
+ """
+ open_policy = graph.open_statements
statuses: dict[str, NodeStatus] = {}
+ # The open statements a proof reaches through each node, as the Lean walk
+ # would: an open statement contributes itself and whatever its statement
+ # reaches, and a proved node is entered fully.
+ reaches: dict[str, frozenset[str]] = {}
for node_id in topological_order(graph):
node = graph.nodes[node_id]
definition = is_definition(node)
@@ -134,8 +163,33 @@ def done(dependency_id: str, attribute: str) -> bool:
dependency = statuses.get(dependency_id)
return dependency is not None and getattr(dependency, attribute)
- can_state = all(done(other, "stated") for other in node.statement_dependencies)
- can_prove = can_state and all(done(other, "proved") for other in node.proof_dependencies)
+ unstated = [other for other in node.statement_dependencies if not done(other, "stated")]
+ # A sorry proof compiles under the open policy, so there a proof
+ # prerequisite only has to be stated.
+ needed = "stated" if open_policy else "proved"
+ unmet: list[str] = []
+ for other in node.proof_dependencies:
+ if other not in unstated and other not in unmet and not done(other, needed):
+ unmet.append(other)
+ assumes: tuple[str, ...] = ()
+ if open_policy:
+ can_state = not unstated
+ can_prove = stated and not unstated and not unmet
+ waiting = unstated + unmet if stated else unstated
+ # Mathlib and unstated nodes reach nothing, so they get no entry.
+ if not node.mathlib:
+ reached = frozenset().union(*(reaches.get(other, frozenset()) for other in node.dependencies))
+ assumes = tuple(sorted(reached))
+ if proved:
+ reaches[node_id] = reached
+ elif stated:
+ reaches[node_id] = frozenset({node_id}).union(
+ *(reaches.get(other, frozenset()) for other in node.statement_dependencies)
+ )
+ else:
+ can_state = not unstated and not unmet
+ can_prove = stated and can_state
+ waiting = unstated + unmet
fully_proved = proved and all(
done(other, "fully_proved") for other in node.dependencies
)
@@ -150,11 +204,16 @@ def done(dependency_id: str, attribute: str) -> bool:
fully_proved=fully_proved,
can_state=can_state,
can_prove=can_prove,
+ assumes=assumes,
)
],
stated=stated,
proved=proved,
fully_proved=fully_proved,
+ can_state=can_state,
+ can_prove=can_prove,
+ assumes=assumes,
+ waiting_on=() if proved else tuple(waiting),
)
return statuses
@@ -168,12 +227,15 @@ def _classify(
fully_proved: bool,
can_state: bool,
can_prove: bool,
+ assumes: tuple[str, ...],
) -> str:
if node.mathlib:
return "mathlib"
if fully_proved:
return "fully_proved"
if proved:
+ if assumes:
+ return "conditional"
return "defined" if definition else "proved"
if stated:
return "can_prove" if can_prove else "stated"
diff --git a/autoform_cli/work.py b/autoform_cli/work.py
index d7c27fed..16a281d6 100644
--- a/autoform_cli/work.py
+++ b/autoform_cli/work.py
@@ -10,6 +10,7 @@
WORK_SCHEMA = "autoform-work/v1"
+ASSUMPTIONS_SCHEMA = "autoform-assumptions/v1"
class WorkError(ValueError):
@@ -42,6 +43,8 @@ class WorkItem:
dependencies: tuple[str, ...]
source_targets: tuple[str, ...]
lean_targets: tuple[WorkLeanTarget, ...]
+ assumes: tuple[str, ...] = ()
+ open_statements: bool = False
@property
def ready(self) -> bool:
@@ -52,11 +55,13 @@ def as_dict(self) -> dict[str, object]:
"article_id": self.article_id,
"article_path": self.article_path,
"article_revision": self.article_revision,
+ "assumes": list(self.assumes),
"blockers": list(self.blockers),
"claim_target": self.claim_target,
"dependencies": list(self.dependencies),
"lean_targets": [target.as_dict() for target in self.lean_targets],
"node_id": self.node_id,
+ "open_statements": self.open_statements,
"phase": self.phase,
"ready": self.ready,
"source_targets": list(self.source_targets),
@@ -69,10 +74,12 @@ def as_dict(self) -> dict[str, object]:
class WorkFrontier:
source_revision: str
items: tuple[WorkItem, ...]
+ open_statements: bool = False
def as_dict(self) -> dict[str, object]:
return {
"schema": WORK_SCHEMA,
+ "open_statements": self.open_statements,
"source_revision": self.source_revision,
"items": [item.as_dict() for item in self.items],
}
@@ -87,7 +94,7 @@ def _phase(node: RuntimeNode, blockers: tuple[str, ...]) -> str | None:
return "proof" if node.status.stated else "statement"
-def _blockers(nodes: dict[str, RuntimeNode], node: RuntimeNode) -> tuple[str, ...]:
+def _blockers(node: RuntimeNode) -> tuple[str, ...]:
# Report each reason where `list_ready_work` enforces it: finished articles
# need no metadata, and an unfinished leaf needs it even when not ready.
if not node.dispatchable:
@@ -103,24 +110,14 @@ def _blockers(nodes: dict[str, RuntimeNode], node: RuntimeNode) -> tuple[str, ..
return tuple(metadata_blockers)
if node.assertions.not_ready:
return ("roadmap:not-ready",)
- # Project CI rejects `sorry`, so a theorem's statement can only land with its
- # proof: both phases wait for the proof prerequisites as well.
- blocked = [
- dependency
- for dependency in node.statement_dependencies
- if (resolved := nodes.get(dependency)) is None or not resolved.status.stated
- ]
- blocked.extend(
- dependency
- for dependency in node.proof_dependencies
- if dependency not in blocked
- and ((resolved := nodes.get(dependency)) is None or not resolved.status.proved)
- )
- return tuple(blocked)
+ # Readiness comes from the derived status, which applies the project's
+ # open-statement policy: strict projects wait for proof prerequisites to be
+ # proved, open-statement projects only for prerequisites to be stated.
+ return node.status.waiting_on
-def _item(nodes: dict[str, RuntimeNode], node: RuntimeNode) -> WorkItem:
- blockers = _blockers(nodes, node)
+def _item(node: RuntimeNode, *, open_statements: bool) -> WorkItem:
+ blockers = _blockers(node)
return WorkItem(
node_id=node.id,
article_id=node.article_id,
@@ -137,6 +134,8 @@ def _item(nodes: dict[str, RuntimeNode], node: RuntimeNode) -> WorkItem:
WorkLeanTarget(target.declaration, target.source_file)
for target in node.lean_targets
),
+ assumes=node.status.assumes,
+ open_statements=open_statements,
)
@@ -174,13 +173,12 @@ def list_ready_work(
"formalizable leaves need durable article revision metadata: "
+ ", ".join(unversioned)
)
- nodes = {node.id: node for node in runtime.nodes}
items = tuple(
item
for node in runtime.nodes
- if (item := _item(nodes, node)).ready
+ if (item := _item(node, open_statements=runtime.open_statements)).ready
)
- return WorkFrontier(runtime.source_revision, items)
+ return WorkFrontier(runtime.source_revision, items, open_statements=runtime.open_statements)
def work_context(
@@ -190,7 +188,6 @@ def work_context(
lean_root: str | Path | None = None,
) -> tuple[str, WorkItem]:
runtime = load_runtime_graph(project_or_blueprint, lean_root=lean_root)
- nodes = {node.id: node for node in runtime.nodes}
matches = [
node
for node in runtime.nodes
@@ -206,15 +203,94 @@ def work_context(
f"{selector!r} matches more than one article: "
+ ", ".join(node.id for node in matches)
)
- return runtime.source_revision, _item(nodes, matches[0])
+ return runtime.source_revision, _item(matches[0], open_statements=runtime.open_statements)
+
+
+@dataclass(frozen=True, slots=True)
+class AssumptionArticle:
+ id: str
+ article_id: str | None
+ state: str
+ declarations: tuple[str, ...]
+ open: bool
+ assumes: tuple[str, ...]
+ allowed_open_declarations: tuple[str, ...]
+
+ def as_dict(self) -> dict[str, object]:
+ return {
+ "allowed_open_declarations": list(self.allowed_open_declarations),
+ "article_id": self.article_id,
+ "assumes": list(self.assumes),
+ "declarations": list(self.declarations),
+ "id": self.id,
+ "open": self.open,
+ "state": self.state,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class AssumptionContract:
+ open_statements: bool
+ source_revision: str
+ articles: tuple[AssumptionArticle, ...]
+
+ def as_dict(self) -> dict[str, object]:
+ return {
+ "schema": ASSUMPTIONS_SCHEMA,
+ "open_statements": self.open_statements,
+ "source_revision": self.source_revision,
+ "articles": [article.as_dict() for article in self.articles],
+ }
+
+ def to_json(self) -> str:
+ return json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":"))
+
+
+def assumption_contract(project_or_blueprint: str | Path) -> AssumptionContract:
+ """Record which open statements each stated article's Lean code may reach.
+
+ CI audits the Lean build against this: an open article's own declarations
+ may keep a ``sorry`` proof, and every other article may reach only the open
+ statements its Markdown dependencies declare. Under the strict policy no
+ article is open and nothing is allowed.
+ """
+ runtime = load_runtime_graph(project_or_blueprint)
+ declarations = {
+ node.id: tuple(target.declaration for target in node.lean_targets)
+ for node in runtime.nodes
+ }
+ articles: list[AssumptionArticle] = []
+ for node in sorted(runtime.nodes, key=lambda candidate: candidate.id):
+ if node.mathlib or not node.status.stated or not declarations[node.id]:
+ continue
+ is_open = runtime.open_statements and node.status.stated and not node.status.proved
+ allowed = {name for assumed in node.status.assumes for name in declarations.get(assumed, ())}
+ if is_open:
+ allowed.update(declarations[node.id])
+ articles.append(
+ AssumptionArticle(
+ id=node.id,
+ article_id=node.article_id,
+ state=node.status.state,
+ declarations=declarations[node.id],
+ open=is_open,
+ assumes=node.status.assumes,
+ allowed_open_declarations=tuple(sorted(allowed)),
+ )
+ )
+ return AssumptionContract(runtime.open_statements, runtime.source_revision, tuple(articles))
__all__ = [
+ "ASSUMPTIONS_SCHEMA",
"WORK_SCHEMA",
+ "AssumptionArticle",
+ "AssumptionContract",
"WorkError",
"WorkFrontier",
"WorkItem",
"WorkLeanTarget",
+ "assumption_contract",
"list_ready_work",
"work_context",
]
diff --git a/tests/test_graph.py b/tests/test_graph.py
index c265fa6b..7d838a41 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -399,3 +399,54 @@ def test_a_chapter_whose_articles_are_all_in_buckets_is_still_refused(tmp_path:
load_graph(tmp_path / "blueprint")
assert "orphan: chapter directory holds 1 article(s) but no README.md" in str(caught.value)
+
+
+@pytest.mark.parametrize(
+ ("value", "allowed"),
+ [
+ (None, False),
+ ("allowed", True),
+ ("forbidden", False),
+ ('"allowed"', True),
+ ("'forbidden'", False),
+ ("ALLOWED", True),
+ ("Forbidden", False),
+ ],
+)
+def test_open_statements_is_read_from_the_roadmap_root(
+ tmp_path: Path, value: str | None, allowed: bool
+) -> None:
+ """Absent means forbidden; values unquote and casefold like every other scalar."""
+ blueprint = tmp_path / "blueprint"
+ policy = "" if value is None else f"open_statements: {value}\n"
+ _roadmap_page(blueprint, "README.md", f"---\n{policy}---\n\n# Roadmap\n")
+ _node(blueprint, "result.md", "# Result\n", declaration="theorem")
+
+ assert load_graph(blueprint).open_statements is allowed
+
+
+def test_rejects_an_open_statements_value_other_than_allowed_or_forbidden(tmp_path: Path) -> None:
+ blueprint = tmp_path / "blueprint"
+ _roadmap_page(blueprint, "README.md", "---\nopen_statements: yes\n---\n\n# Roadmap\n")
+
+ with pytest.raises(GraphValidationError) as caught:
+ load_graph(blueprint)
+
+ assert caught.value.issues == ("roadmap:2: 'open_statements' accepts allowed or forbidden",)
+
+
+@pytest.mark.parametrize(("relative", "node_id"), [("result.md", "result"), ("chapter/README.md", "chapter")])
+def test_open_statements_set_outside_the_roadmap_root_is_refused(
+ tmp_path: Path, relative: str, node_id: str
+) -> None:
+ """The policy belongs to the project, so a chapter or article cannot opt in on its own."""
+ blueprint = tmp_path / "blueprint"
+ _roadmap_page(blueprint, "README.md", "---\n---\n\n# Roadmap\n")
+ _node(blueprint, relative, "# Result\n", open_statements="forbidden")
+
+ with pytest.raises(GraphValidationError) as caught:
+ load_graph(blueprint)
+
+ assert caught.value.issues == (
+ f"{node_id}: open_statements is a project policy; set it only in roadmap/README.md",
+ )
diff --git a/tests/test_render.py b/tests/test_render.py
index 0eb5d7a0..ae5cfea8 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -1275,3 +1275,58 @@ def test_a_node_that_is_the_current_page_links_as_a_bare_fragment(tmp_path: Path
"chapter": "#",
"chapter/x": "#x",
}
+
+
+def _conditional_project(tmp_path: Path, policy: str) -> Path:
+ """`_project` with Top proved from an open statement, under the given policy."""
+ project = _project(tmp_path)
+ roadmap = project / "blueprint" / "roadmap"
+ (roadmap / "README.md").write_text(
+ f"---\nopen_statements: {policy}\n---\n\n# Roadmap\n\n"
+ "## Definitions\n\n- [Base](base.md)\n\n"
+ "## Results\n\n- [Open](open.md)\n- [Top](top.md)\n",
+ encoding="utf-8",
+ )
+ (roadmap / "open.md").write_text(
+ "---\ndeclaration: theorem\nstatement: formalized\n---\n\n"
+ "# Open\n\nA statement whose proof is still sorry.\n\n## Depends on\n\n- [Base](base.md)\n",
+ encoding="utf-8",
+ )
+ top = roadmap / "top.md"
+ top.write_text(
+ top.read_text(encoding="utf-8") + "\n## Proof depends on\n\n- [Open](open.md)\n", encoding="utf-8"
+ )
+ return project
+
+
+def test_a_conditional_proof_names_the_open_statements_it_assumes(tmp_path: Path) -> None:
+ project = _conditional_project(tmp_path, "allowed")
+
+ render_site(project / "blueprint", tmp_path / "out", lean_root=project)
+ page = (tmp_path / "out/roadmap/README.md").read_text(encoding="utf-8")
+ top = page[page.index('id="top"'):]
+
+ assert '
conditionally proved' in top
+ assert (
+ '
Assumes'
+ 'Theorem 1 (Open)'
+ " (open statements whose Lean proofs are still sorry)"
+ ) in top
+ # Only the conditional proof carries the row; the open statement assumes nothing.
+ assert page.count('
Assumes') == 1
+
+ css = (tmp_path / "out/stylesheets/blueprint.css").read_text(encoding="utf-8")
+ assert ".bp-ref-conditional::before, .bp-swatch-conditional { background: #E9DFFC; border-color: #6B3FCF; }" in css
+ assert "[data-md-color-scheme=slate] .bp-ref-conditional::before" in css
+ assert ".bp-conditional .bp-mark { color: #6B3FCF; }" in css
+
+
+def test_the_strict_policy_shows_the_same_proof_as_proved_with_no_assumptions(tmp_path: Path) -> None:
+ project = _conditional_project(tmp_path, "forbidden")
+
+ render_site(project / "blueprint", tmp_path / "out", lean_root=project)
+ page = (tmp_path / "out/roadmap/README.md").read_text(encoding="utf-8")
+
+ assert '
Assumes' not in page
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
index 247419ea..f67c09ae 100644
--- a/tests/test_runtime.py
+++ b/tests/test_runtime.py
@@ -284,3 +284,61 @@ def test_adapter_rejects_inconsistent_hand_built_graph_without_host_paths(tmp_pa
"chapter/section/base: dependency union does not match typed dependencies",
)
assert str(tmp_path) not in str(error.value)
+
+
+def _policy_project(tmp_path: Path, policy: str | None) -> Path:
+ """`_project` plus an open theorem, a reduction proved from it, and a statement waiting on a gap."""
+ project = _project(tmp_path)
+ if policy is not None:
+ _article(project, "README.md", title="Roadmap", open_statements=policy)
+ theorem = {"declaration": "theorem", "statement": "formalized"}
+ _article(project, "chapter/section/open.md", title="Open", lean="Project.open_thm", **theorem)
+ _article(
+ project,
+ "chapter/section/reduction.md",
+ title="Reduction",
+ lean="Project.reduction",
+ proof="formalized",
+ proof_dependencies=("open.md",),
+ **theorem,
+ )
+ _article(project, "chapter/section/gap.md", title="Gap", declaration="theorem")
+ _article(project, "chapter/section/waiting.md", title="Waiting", proof_dependencies=("gap.md",), **theorem)
+ return project
+
+
+def _status_payloads(project: Path) -> tuple[bool, dict[str, dict[str, object]]]:
+ payload = json.loads(load_runtime_graph(project).to_json())
+ return payload["open_statements"], {
+ node["id"].removeprefix("chapter/section/"): node["status"] for node in payload["nodes"]
+ }
+
+
+def test_strict_runtime_readiness_comes_from_the_derived_status(tmp_path: Path) -> None:
+ """Runtime readiness once ignored proof prerequisites that `work list` enforced."""
+ open_statements, statuses = _status_payloads(_policy_project(tmp_path, None))
+
+ assert open_statements is False
+ reduction = statuses["reduction"]
+ assert (reduction["state"], reduction["can_state"], reduction["can_prove"]) == ("proved", False, False)
+ assert (reduction["assumes"], reduction["waiting_on"]) == ([], [])
+ waiting = statuses["waiting"]
+ assert (waiting["state"], waiting["can_state"], waiting["can_prove"]) == ("stated", False, False)
+ assert waiting["waiting_on"] == ["chapter/section/gap"]
+ assert all(status["assumes"] == [] for status in statuses.values())
+
+
+def test_open_runtime_records_the_policy_and_what_each_proof_assumes(tmp_path: Path) -> None:
+ open_statements, statuses = _status_payloads(_policy_project(tmp_path, "allowed"))
+
+ assert open_statements is True
+ reduction = statuses["reduction"]
+ assert (reduction["state"], reduction["can_state"], reduction["can_prove"]) == ("conditional", True, True)
+ assert reduction["assumes"] == ["chapter/section/open"]
+ assert reduction["waiting_on"] == []
+ assert not reduction["fully_proved"]
+ waiting = statuses["waiting"]
+ assert (waiting["state"], waiting["can_state"], waiting["can_prove"]) == ("stated", True, False)
+ assert (waiting["assumes"], waiting["waiting_on"]) == ([], ["chapter/section/gap"])
+ assert statuses["open"]["state"] == "can_prove"
+ assert statuses["open"]["assumes"] == []
diff --git a/tests/test_status.py b/tests/test_status.py
index 0fad04b4..68ee4195 100644
--- a/tests/test_status.py
+++ b/tests/test_status.py
@@ -4,8 +4,8 @@
import pytest
-from autoform_cli.graph import load_graph
-from autoform_cli.status import derive, summarize
+from autoform_cli.graph import Graph, Node, load_graph
+from autoform_cli.status import NodeStatus, derive, summarize
def _node(blueprint: Path, relative: str, body: str = "", **metadata: str) -> None:
@@ -172,3 +172,163 @@ def test_assumptions_and_propositions_keep_the_proof_obligation(
_node(blueprint, "d.md", declaration=declaration, statement="formalized")
assert not derive(load_graph(blueprint))["d"].proved
+
+
+def _mixed_statuses(blueprint: Path, policy: str) -> dict[str, NodeStatus]:
+ """One graph with every kind of node the two policies treat differently.
+
+ ``hidden``, ``open``, ``inner`` and ``side`` are open statements: stated
+ theorems with no Lean proof. ``reduction`` and ``top`` are proved on top of
+ them, ``up`` is in Mathlib, and ``gap`` and ``bridge`` are not stated.
+ """
+ _node(blueprint, "README.md", open_statements=policy)
+ _node(blueprint, "kind.md", declaration="def", statement="formalized")
+ theorem = {"declaration": "theorem"}
+ stated = {**theorem, "statement": "formalized"}
+ proved = {**stated, "proof": "formalized"}
+ _node(blueprint, "lemma.md", "## Depends on\n\n- [Kind](kind.md)\n", **proved)
+ _node(blueprint, "hidden.md", **stated)
+ _node(blueprint, "up.md", "## Depends on\n\n- [Hidden](hidden.md)\n", **theorem, mathlib="true")
+ _node(blueprint, "open.md", "## Depends on\n\n- [Kind](kind.md)\n", **stated)
+ _node(
+ blueprint,
+ "reduction.md",
+ "## Depends on\n\n- [Lemma](lemma.md)\n\n## Proof depends on\n\n- [Open](open.md)\n",
+ **proved,
+ )
+ _node(blueprint, "gap.md", **theorem)
+ _node(blueprint, "inner.md", **stated)
+ _node(blueprint, "side.md", **stated)
+ _node(
+ blueprint,
+ "outer.md",
+ "## Depends on\n\n- [Inner](inner.md)\n\n## Proof depends on\n\n- [Side](side.md)\n",
+ **stated,
+ )
+ _node(blueprint, "bridge.md", "## Depends on\n\n- [Open](open.md)\n", **theorem)
+ _node(
+ blueprint,
+ "top.md",
+ "## Depends on\n\n- [Up](up.md)\n\n## Proof depends on\n\n- [Outer](outer.md)\n- [Bridge](bridge.md)\n",
+ **proved,
+ )
+ _node(
+ blueprint,
+ "waits.md",
+ "## Depends on\n\n- [Gap](gap.md)\n\n## Proof depends on\n\n- [Gap](gap.md)\n- [Bridge](bridge.md)\n",
+ **theorem,
+ )
+ _node(
+ blueprint,
+ "pending.md",
+ "## Proof depends on\n\n- [Gap](gap.md)\n- [Reduction](reduction.md)\n",
+ **stated,
+ )
+ return derive(load_graph(blueprint))
+
+
+def _readiness(
+ statuses: dict[str, NodeStatus],
+) -> dict[str, tuple[str, bool, bool, tuple[str, ...], tuple[str, ...]]]:
+ return {
+ node_id: (status.key, status.can_state, status.can_prove, status.waiting_on, status.assumes)
+ for node_id, status in statuses.items()
+ }
+
+
+def test_the_strict_policy_waits_for_proof_prerequisites_to_be_proved(tmp_path: Path) -> None:
+ statuses = _mixed_statuses(tmp_path / "blueprint", "forbidden")
+
+ assert _readiness(statuses) == {
+ "roadmap": ("can_state", True, False, (), ()),
+ "kind": ("fully_proved", True, True, (), ()),
+ "lemma": ("fully_proved", True, True, (), ()),
+ "hidden": ("can_prove", True, True, (), ()),
+ "up": ("mathlib", True, True, (), ()),
+ "open": ("can_prove", True, True, (), ()),
+ # Proved, so nothing is waited on, but under this policy its unproved
+ # proof prerequisite would have kept the statement from landing.
+ "reduction": ("proved", False, False, (), ()),
+ "gap": ("can_state", True, False, (), ()),
+ "inner": ("can_prove", True, True, (), ()),
+ "side": ("can_prove", True, True, (), ()),
+ "outer": ("stated", False, False, ("side",), ()),
+ "bridge": ("can_state", True, False, (), ()),
+ "top": ("proved", False, False, (), ()),
+ # Unstated statement prerequisites first, then unproved proof ones, once each.
+ "waits": ("planned", False, False, ("gap", "bridge"), ()),
+ "pending": ("stated", False, False, ("gap",), ()),
+ }
+
+
+def test_the_open_policy_waits_only_for_prerequisites_to_be_stated(tmp_path: Path) -> None:
+ statuses = _mixed_statuses(tmp_path / "blueprint", "allowed")
+
+ assert _readiness(statuses) == {
+ "roadmap": ("can_state", True, False, (), ()),
+ "kind": ("fully_proved", True, True, (), ()),
+ "lemma": ("fully_proved", True, True, (), ()),
+ "hidden": ("can_prove", True, True, (), ()),
+ "up": ("mathlib", True, True, (), ()),
+ "open": ("can_prove", True, True, (), ()),
+ "reduction": ("conditional", True, True, (), ("open",)),
+ "gap": ("can_state", True, False, (), ()),
+ "inner": ("can_prove", True, True, (), ()),
+ "side": ("can_prove", True, True, (), ()),
+ "outer": ("can_prove", True, True, (), ("inner", "side")),
+ "bridge": ("can_state", True, False, (), ("open",)),
+ # can_prove reads the prerequisites, not the assertion: bridge is not stated.
+ "top": ("conditional", True, False, (), ("inner", "outer")),
+ # An unstated node waits only on its statement prerequisites.
+ "waits": ("planned", False, False, ("gap",), ()),
+ "pending": ("stated", True, False, ("gap",), ("open",)),
+ }
+
+
+@pytest.mark.parametrize("policy", ["forbidden", "allowed"])
+def test_a_fully_proved_node_assumes_nothing(tmp_path: Path, policy: str) -> None:
+ statuses = _mixed_statuses(tmp_path / "blueprint", policy)
+
+ fully_proved = {node_id for node_id, status in statuses.items() if status.fully_proved}
+ assert fully_proved == {"kind", "lemma"}
+ assert all(status.assumes == () for status in statuses.values() if status.fully_proved)
+
+
+def test_assumptions_reach_what_the_lean_proof_can_reach(tmp_path: Path) -> None:
+ """``top`` rests on ``up``, ``outer`` and ``bridge``, and each stops the walk differently.
+
+ Mathlib ``up`` is upstream, so ``hidden`` behind it is not reached. ``bridge``
+ is not stated, so it has no Lean declaration to lead to ``open``. ``outer``
+ is open: its own sorry and its statement prerequisite ``inner`` are reached,
+ but ``side``, which only its missing proof would use, is not.
+ """
+ statuses = _mixed_statuses(tmp_path / "blueprint", "allowed")
+
+ assert statuses["top"].assumes == ("inner", "outer")
+ # The walk stops at bridge for its dependents, but bridge's own proof would
+ # still rest on open. A Mathlib node rests on nothing.
+ assert statuses["bridge"].assumes == ("open",)
+ assert statuses["up"].assumes == ()
+
+
+@pytest.mark.parametrize("open_statements", [False, True])
+def test_a_missing_dependency_counts_as_neither_stated_nor_proved(
+ tmp_path: Path, open_statements: bool
+) -> None:
+ node = Node(
+ id="result",
+ title="Result",
+ path=tmp_path / "result.md",
+ dependencies=("ghost", "phantom"),
+ statement_dependencies=("ghost",),
+ proof_dependencies=("phantom",),
+ declaration="theorem",
+ statement_formalized=True,
+ )
+ graph = Graph(blueprint_dir=tmp_path, nodes={"result": node}, open_statements=open_statements)
+
+ status = derive(graph)["result"]
+
+ assert (status.key, status.can_state, status.can_prove) == ("stated", False, False)
+ assert status.waiting_on == ("ghost", "phantom")
+ assert status.assumes == ()
diff --git a/tests/test_visualization.py b/tests/test_visualization.py
index a85dda67..85d6af3c 100644
--- a/tests/test_visualization.py
+++ b/tests/test_visualization.py
@@ -132,6 +132,27 @@ def test_green_stops_at_an_unproved_prerequisite(tmp_path: Path) -> None:
assert statuses["top"].key == "proved"
+def test_a_conditional_proof_has_its_own_colour_and_legend_entry(tmp_path: Path) -> None:
+ """A proof resting on a sorry must not share the fully proved green."""
+ blueprint = tmp_path / "blueprint"
+ _write_node(blueprint / "roadmap" / "README.md", "Roadmap", open_statements="allowed")
+ _write_node(blueprint / "roadmap" / "open.md", "Open", declaration="theorem", statement="formalized")
+ _write_node(
+ blueprint / "roadmap" / "top.md",
+ "Top",
+ [("Open", "open.md")],
+ declaration="theorem",
+ statement="formalized",
+ proof="formalized",
+ )
+
+ document = export_graph(blueprint).read_text(encoding="utf-8")
+
+ assert '("Top"):::conditional' in document
+ assert f"classDef conditional fill:{_state('conditional').fill}" in document
+ assert '
' in document
+ assert "Proof compiles, but rests on an open statement whose Lean proof is still sorry." in document
+
def test_cli_writes_only_the_graph_by_default(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
diff --git a/tests/test_work.py b/tests/test_work.py
index efc094c1..a13c7310 100644
--- a/tests/test_work.py
+++ b/tests/test_work.py
@@ -325,19 +325,22 @@ def test_work_cli_emits_stable_json(tmp_path: Path, capsys) -> None:
assert cli.main(["work", "list", str(project), "--lean-root", str(project), "--json"]) == 0
frontier = json.loads(capsys.readouterr().out)
- assert set(frontier) == {"items", "schema", "source_revision"}
+ assert set(frontier) == {"items", "open_statements", "schema", "source_revision"}
assert frontier["schema"] == WORK_SCHEMA
+ assert frontier["open_statements"] is False
assert frontier["source_revision"] == source_revision
assert [item["phase"] for item in frontier["items"]] == ["proof", "statement"]
assert frontier["items"][0] == {
"article_id": "af_000000000000000000000003",
"article_path": "blueprint/roadmap/chapter/prove.md",
"article_revision": prove_revision,
+ "assumes": [],
"blockers": [],
"claim_target": "af_000000000000000000000003",
"dependencies": ["chapter/base"],
"lean_targets": [{"declaration": "Project.prove", "source_file": "Project.lean"}],
"node_id": "chapter/prove",
+ "open_statements": False,
"phase": "proof",
"ready": True,
"source_targets": [],
@@ -481,3 +484,335 @@ def test_node_ids_cannot_impersonate_article_ids(tmp_path: Path, capsys) -> None
assert cli.main(["work", "context", "af_000000000000000000000002", str(project)]) == 2
assert "node id has the form of an article_id" in capsys.readouterr().err
+
+
+def _policy_project(tmp_path: Path, policy: str | None) -> Path:
+ """`_project` plus an open theorem, a reduction proved from it, and articles resting on them.
+
+ *policy* is the `open_statements` value in `roadmap/README.md`; ``None``
+ writes no roadmap page, so the project keeps the default strict policy.
+ """
+ project = _project(tmp_path)
+ if policy is not None:
+ (project / "blueprint/roadmap/README.md").write_text(
+ f"---\nopen_statements: {policy}\n---\n\n# Roadmap\n", encoding="utf-8"
+ )
+ stated = ["declaration: theorem", "statement: formalized"]
+ _article(
+ project,
+ "open.md",
+ title="Open",
+ metadata=["article_id: af_00000000000000000000000b", *stated, "lean: Project.open_thm Project.open_aux"],
+ depends="base.md",
+ )
+ _article(
+ project,
+ "reduction.md",
+ title="Reduction",
+ metadata=["article_id: af_00000000000000000000000c", *stated, "proof: formalized", "lean: Project.reduction"],
+ proof_depends="open.md",
+ )
+ _article(
+ project,
+ "uses.md",
+ title="Uses",
+ metadata=["article_id: af_00000000000000000000000d", *stated, "lean: Project.uses"],
+ depends="open.md",
+ )
+ _article(
+ project,
+ "corollary.md",
+ title="Corollary",
+ metadata=["article_id: af_00000000000000000000000e", "declaration: theorem"],
+ depends="reduction.md",
+ )
+ # Neither belongs in the assumption contract: one is upstream, the other names no declaration.
+ _article(
+ project,
+ "upstream.md",
+ title="Upstream",
+ metadata=["declaration: theorem", "mathlib: true", "lean: Project.upstream"],
+ )
+ _article(project, "unnamed.md", title="Unnamed", metadata=["declaration: def", "statement: formalized"])
+ return project
+
+
+def _blocked_articles(project: Path) -> None:
+ """Add articles whose prerequisites block them differently under the two policies."""
+ _article(
+ project,
+ "waits.md",
+ title="Waits",
+ metadata=["article_id: af_000000000000000000000010", "declaration: theorem"],
+ depends="state.md",
+ proof_depends="prove.md",
+ )
+ _edit(project, "waits.md", "- [dependency](prove.md)", "- [dependency](prove.md)\n- [dependency](state.md)")
+ _article(
+ project,
+ "stuck.md",
+ title="Stuck",
+ metadata=[
+ "article_id: af_000000000000000000000011",
+ "declaration: theorem",
+ "statement: formalized",
+ "lean: Project.stuck",
+ ],
+ depends="blocked.md",
+ proof_depends="prove.md",
+ )
+ _edit(project, "stuck.md", "- [dependency](prove.md)", "- [dependency](prove.md)\n- [dependency](state.md)")
+ _article(
+ project,
+ "main.md",
+ title="Main",
+ metadata=[
+ "article_id: af_000000000000000000000012",
+ "declaration: theorem",
+ "statement: formalized",
+ "lean: Project.main",
+ ],
+ proof_depends="prove.md",
+ )
+
+
+def _blockers(project: Path) -> dict[str, tuple[str | None, tuple[str, ...]]]:
+ selectors = ("chapter/waits", "chapter/stuck", "chapter/main")
+ items = {selector: work_context(project, selector)[1] for selector in selectors}
+ return {selector: (item.phase, item.blockers) for selector, item in items.items()}
+
+
+def test_strict_blockers_list_unstated_statement_prerequisites_then_unproved_proof_ones(
+ tmp_path: Path,
+) -> None:
+ project = _project(tmp_path)
+ _blocked_articles(project)
+
+ assert _blockers(project) == {
+ "chapter/waits": (None, ("chapter/state", "chapter/prove")),
+ "chapter/stuck": (None, ("chapter/blocked", "chapter/prove", "chapter/state")),
+ "chapter/main": (None, ("chapter/prove",)),
+ }
+
+
+def test_open_blockers_wait_only_for_prerequisites_to_be_stated(tmp_path: Path) -> None:
+ project = _project(tmp_path)
+ (project / "blueprint/roadmap/README.md").write_text(
+ "---\nopen_statements: allowed\n---\n\n# Roadmap\n", encoding="utf-8"
+ )
+ _blocked_articles(project)
+
+ assert _blockers(project) == {
+ # Unstated, so only its statement prerequisites can hold it back.
+ "chapter/waits": (None, ("chapter/state",)),
+ # Stated: prove is stated too, so only the unstated prerequisites remain.
+ "chapter/stuck": (None, ("chapter/blocked", "chapter/state")),
+ "chapter/main": ("proof", ()),
+ }
+ _, main = work_context(project, "chapter/main")
+ assert main.assumes == ("chapter/prove",)
+ assert "chapter/main" in {item.node_id for item in list_ready_work(project).items}
+
+
+def test_strict_work_text_is_unchanged(tmp_path: Path, capsys) -> None:
+ project = _policy_project(tmp_path, None)
+ revision = load_runtime_graph(project).source_revision
+ uses = hashlib.sha256((project / "blueprint/roadmap/chapter/uses.md").read_bytes()).hexdigest()
+
+ assert cli.main(["work", "list", str(project)]) == 0
+ assert capsys.readouterr().out == (
+ "statement: chapter/corollary [af_00000000000000000000000e] - Corollary\n"
+ "proof: chapter/open [af_00000000000000000000000b] - Open\n"
+ "proof: chapter/prove [af_000000000000000000000003] - Prove me\n"
+ "statement: chapter/state [af_000000000000000000000002] - State me\n"
+ "proof: chapter/uses [af_00000000000000000000000d] - Uses\n"
+ )
+
+ assert cli.main(["work", "context", "chapter/uses", str(project)]) == 0
+ assert capsys.readouterr().out == (
+ "Uses (chapter/uses)\n"
+ "State: can_prove\n"
+ "Phase: proof\n"
+ "Claim target: af_00000000000000000000000d\n"
+ "Article: blueprint/roadmap/chapter/uses.md\n"
+ f"Article revision: {uses}\n"
+ f"Graph source revision: {revision}\n"
+ "Dependencies: chapter/open\n"
+ "Lean: Project.uses\n"
+ )
+
+
+def test_open_work_text_names_the_policy_and_what_each_item_assumes(tmp_path: Path, capsys) -> None:
+ project = _policy_project(tmp_path, "allowed")
+ revision = load_runtime_graph(project).source_revision
+ uses = hashlib.sha256((project / "blueprint/roadmap/chapter/uses.md").read_bytes()).hexdigest()
+
+ assert cli.main(["work", "list", str(project)]) == 0
+ assert capsys.readouterr().out == (
+ "Open statements: allowed (a statement may land with a sorry proof)\n"
+ "statement: chapter/corollary [af_00000000000000000000000e] - Corollary\n"
+ " assumes: chapter/open\n"
+ "proof: chapter/open [af_00000000000000000000000b] - Open\n"
+ "proof: chapter/prove [af_000000000000000000000003] - Prove me\n"
+ "statement: chapter/state [af_000000000000000000000002] - State me\n"
+ "proof: chapter/uses [af_00000000000000000000000d] - Uses\n"
+ " assumes: chapter/open\n"
+ )
+
+ assert cli.main(["work", "context", "chapter/uses", str(project)]) == 0
+ assert capsys.readouterr().out == (
+ "Uses (chapter/uses)\n"
+ "State: can_prove\n"
+ "Phase: proof\n"
+ "Open statements: allowed\n"
+ "Assumes: chapter/open\n"
+ "Claim target: af_00000000000000000000000d\n"
+ "Article: blueprint/roadmap/chapter/uses.md\n"
+ f"Article revision: {uses}\n"
+ f"Graph source revision: {revision}\n"
+ "Dependencies: chapter/open\n"
+ "Lean: Project.uses\n"
+ )
+
+ assert cli.main(["work", "context", "chapter/prove", str(project)]) == 0
+ context = capsys.readouterr().out.splitlines()
+ assert "Open statements: allowed" in context
+ assert not any(line.startswith("Assumes:") for line in context)
+
+ assert cli.main(["work", "list", str(project), "--json"]) == 0
+ frontier = json.loads(capsys.readouterr().out)
+ assert frontier["open_statements"] is True
+ assert {item["node_id"]: item["assumes"] for item in frontier["items"]} == {
+ "chapter/corollary": ["chapter/open"],
+ "chapter/open": [],
+ "chapter/prove": [],
+ "chapter/state": [],
+ "chapter/uses": ["chapter/open"],
+ }
+ assert all(item["open_statements"] is True for item in frontier["items"])
+
+
+def _contract_article(
+ node_id: str,
+ article_id: str,
+ state: str,
+ declarations: list[str],
+ *,
+ open_: bool = False,
+ assumes: tuple[str, ...] = (),
+ allowed: tuple[str, ...] = (),
+) -> dict[str, object]:
+ return {
+ "allowed_open_declarations": list(allowed),
+ "article_id": article_id,
+ "assumes": list(assumes),
+ "declarations": declarations,
+ "id": node_id,
+ "open": open_,
+ "state": state,
+ }
+
+
+def test_work_assumptions_under_the_strict_policy_lists_articles_with_nothing_open(
+ tmp_path: Path, capsys
+) -> None:
+ project = _policy_project(tmp_path, "forbidden")
+
+ assert cli.main(["work", "assumptions", str(project), "--json"]) == 0
+ output = capsys.readouterr().out
+ contract = json.loads(output)
+ assert output == json.dumps(contract, sort_keys=True, separators=(",", ":")) + "\n"
+ assert contract == {
+ "schema": work_module.ASSUMPTIONS_SCHEMA,
+ "open_statements": False,
+ "source_revision": load_runtime_graph(project).source_revision,
+ "articles": [
+ _contract_article("chapter/base", "af_000000000000000000000001", "fully_proved", ["Project.base"]),
+ _contract_article(
+ "chapter/open", "af_00000000000000000000000b", "can_prove", ["Project.open_thm", "Project.open_aux"]
+ ),
+ _contract_article("chapter/prove", "af_000000000000000000000003", "can_prove", ["Project.prove"]),
+ _contract_article("chapter/reduction", "af_00000000000000000000000c", "proved", ["Project.reduction"]),
+ _contract_article("chapter/uses", "af_00000000000000000000000d", "can_prove", ["Project.uses"]),
+ ],
+ }
+
+ assert cli.main(["work", "assumptions", str(project)]) == 0
+ assert capsys.readouterr().out == "Open statements: forbidden\n"
+
+
+def test_work_assumptions_under_the_open_policy_bounds_each_article(
+ tmp_path: Path, capsys, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ project = _policy_project(tmp_path, "allowed")
+ open_declarations = ("Project.open_aux", "Project.open_thm")
+
+ assert cli.main(["work", "assumptions", str(project), "--json"]) == 0
+ contract = json.loads(capsys.readouterr().out)
+ assert contract == {
+ "schema": "autoform-assumptions/v1",
+ "open_statements": True,
+ "source_revision": load_runtime_graph(project).source_revision,
+ "articles": [
+ _contract_article("chapter/base", "af_000000000000000000000001", "fully_proved", ["Project.base"]),
+ # Declarations keep their authored order; the allowance is sorted.
+ _contract_article(
+ "chapter/open",
+ "af_00000000000000000000000b",
+ "can_prove",
+ ["Project.open_thm", "Project.open_aux"],
+ open_=True,
+ allowed=open_declarations,
+ ),
+ _contract_article(
+ "chapter/prove",
+ "af_000000000000000000000003",
+ "can_prove",
+ ["Project.prove"],
+ open_=True,
+ allowed=("Project.prove",),
+ ),
+ _contract_article(
+ "chapter/reduction",
+ "af_00000000000000000000000c",
+ "conditional",
+ ["Project.reduction"],
+ assumes=("chapter/open",),
+ allowed=open_declarations,
+ ),
+ _contract_article(
+ "chapter/uses",
+ "af_00000000000000000000000d",
+ "can_prove",
+ ["Project.uses"],
+ open_=True,
+ assumes=("chapter/open",),
+ allowed=(*open_declarations, "Project.uses"),
+ ),
+ ],
+ }
+
+ monkeypatch.chdir(project)
+ assert cli.main(["work", "assumptions", "--json"]) == 0
+ assert json.loads(capsys.readouterr().out) == contract
+
+ assert cli.main(["work", "assumptions", str(project / "blueprint")]) == 0
+ assert capsys.readouterr().out == (
+ "Open statements: allowed\n"
+ "open: chapter/open (Project.open_thm, Project.open_aux)\n"
+ "open: chapter/prove (Project.prove)\n"
+ "conditional: chapter/reduction assumes chapter/open\n"
+ "open: chapter/uses (Project.uses)\n"
+ "conditional: chapter/uses assumes chapter/open\n"
+ )
+
+
+def test_work_assumptions_reports_errors_on_stderr_with_exit_2(tmp_path: Path, capsys) -> None:
+ assert cli.main(["work", "assumptions", str(tmp_path / "missing")]) == 2
+ assert capsys.readouterr().err == "error: project or blueprint directory does not exist\n"
+
+ project = _policy_project(tmp_path, "maybe")
+ assert cli.main(["work", "assumptions", str(project), "--json"]) == 2
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert captured.err == "error: roadmap:2: 'open_statements' accepts allowed or forbidden\n"
From ce0c1235ef3e6a7f5155f0c47f5bdde3a2ab4300 Mon Sep 17 00:00:00 2001
From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com>
Date: Mon, 5 Oct 2026 05:31:52 -0400
Subject: [PATCH 02/38] Pin readiness wording, Assumes row order and the empty
open frontier
Nothing pinned the reworded legend meanings for can_prove and can_state
or the policy-neutral Next up explanations, so add a legend test and a
landing-page test for both phases. The conditional article test now
checks that the Assumes row sits after the Lean row and before
Discussion, as spec A3 places it. Under the open policy, `work list`
names the policy even when nothing is ready; a test covers that branch.
Also restore the two blank lines after the conditional legend test.
---
tests/test_render.py | 25 +++++++++++++++++++++++++
tests/test_visualization.py | 15 +++++++++++++++
tests/test_work.py | 14 ++++++++++++++
3 files changed, 54 insertions(+)
diff --git a/tests/test_render.py b/tests/test_render.py
index ae5cfea8..cee93485 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -1315,6 +1315,9 @@ def test_a_conditional_proof_names_the_open_statements_it_assumes(tmp_path: Path
) in top
# Only the conditional proof carries the row; the open statement assumes nothing.
assert page.count('Assumes') == 1
+ # The row follows the implementation row and precedes Discussion.
+ meta = top[top.index('