Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.

**Verified this pass:** `pytest` → green, 2,145 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.6` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
**Verified this pass:** `pytest` → green, 2,148 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.6` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.

[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.

Expand Down
42 changes: 42 additions & 0 deletions grapharc/planner/admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,8 +672,50 @@ def _check_registry(self, proposal: Subgraph) -> list[Rejection]:
def _check_endpoints(
self, path: str, edge: ProposedEdge, names: frozenset[str]
) -> list[Rejection]:
"""Both endpoints must name something, and the sentinels must face the
right way.

The sentinels are directional and the kernel enforces it: `START` is the
graph's entry, so it can only be a source, and `END` is its exit, so it
can only be a target. Accepting them in either role let a proposal
carrying `END -> x` or `x -> START` through admission and into
materialisation, where `StateGraph` raises "END cannot be a start node"
/ "START cannot be an end node" — a shape defect surfacing as a
`MaterializationError`.

That is the wrong failure in two ways. It is charged to
`max_consecutive_execution_failures` (2) rather than
`max_consecutive_rejections` (3), so a planner gets fewer tries at a
mistake admission is supposed to catch. And the planner is handed prose
— "The subgraph you proposed did not run: could not be built: ..." —
instead of a `Rejection` with a code and a remedy, which is the whole
contract of `feedback()`: a rejection is data the next round can act on.
"""
out: list[Rejection] = []
for role, endpoint in (("source", edge.source), ("target", edge.target)):
wrong_way = (endpoint == END and role == "source") or (
endpoint == START and role == "target"
)
if wrong_way:
other = END if endpoint == START else START
out.append(
Rejection(
check=Check.REGISTRY,
code="sentinel_wrong_direction",
subject=_scoped(path, edge.render()),
detail=(
f"{endpoint!r} is the graph's "
f"{'entry' if endpoint == START else 'exit'}, so it cannot "
f"be an edge's {role}"
),
remedy=(
f"use {endpoint!r} as the edge's "
f"{'source' if endpoint == START else 'target'}, "
f"or {other!r} here"
),
)
)
continue
if endpoint in _SENTINELS or endpoint in names or endpoint in self.known_nodes:
continue
out.append(
Expand Down
69 changes: 69 additions & 0 deletions tests/test_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,75 @@ def test_an_edge_to_a_node_that_does_not_exist_is_rejected():
assert "target" in reason.detail


def test_end_cannot_be_an_edge_source_and_start_cannot_be_a_target():
"""The sentinels are directional, and the gate has to say so.

`START` is the graph's entry and `END` its exit. Both used to be accepted
in either role, so a proposal carrying `END -> x` or `x -> START` was
admitted and then failed in `Materializer` with `StateGraph`'s own
"END cannot be a start node" — a shape defect surfacing as a
`MaterializationError` rather than a rejection. That is charged to the
execution-failure allowance rather than the rejection allowance, and it
reaches the planner as prose instead of a code and a remedy.
"""
backwards_end = Subgraph(
nodes=(ProposedNode(name="fetch"),),
edges=(
ProposedEdge(source=START, target="fetch"),
ProposedEdge(source=END, target="fetch"),
),
)
result = checker(registry("fetch")).check(backwards_end)

assert not result.admitted
(reason,) = result.reasons(Check.REGISTRY)
assert reason.code == "sentinel_wrong_direction"
assert "source" in reason.detail
assert reason.remedy

backwards_start = Subgraph(
nodes=(ProposedNode(name="fetch"),),
edges=(
ProposedEdge(source=START, target="fetch"),
ProposedEdge(source="fetch", target=START),
),
)
result = checker(registry("fetch")).check(backwards_start)

assert not result.admitted
(reason,) = result.reasons(Check.REGISTRY)
assert reason.code == "sentinel_wrong_direction"
assert "target" in reason.detail


def test_the_sentinels_still_work_the_way_round_they_are_meant_to():
"""A guard on the guard: the fix must not refuse the normal shape."""
result = checker(
registry("fetch"), limits=AdmissionLimits(require_entry=True)
).check(linear("fetch"))

assert result.admitted, result.rejections


def test_a_refused_sentinel_edge_never_reaches_materialisation():
"""The point of catching it here: `Materializer` raises on these, and the
loop counts that against a different, smaller allowance."""
proposal = Subgraph(
nodes=(ProposedNode(name="fetch"),),
edges=(
ProposedEdge(source=START, target="fetch"),
ProposedEdge(source=END, target="fetch"),
),
)
gate = checker(registry("fetch"))
result = gate.check(proposal)

assert not result.admitted
# `_explode` is every registered kind's factory, so anything that built the
# graph anyway would raise AssertionError rather than fail this quietly.
assert result.failed_checks() == (Check.REGISTRY,)


def test_an_edge_may_reference_a_node_already_in_the_graph():
proposal = Subgraph(
nodes=(ProposedNode(name="fetch"),),
Expand Down
Loading