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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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