Skip to content

fix: render node rationale in query, get_node and explain output (#2821) - #4576

Open
yotamleo wants to merge 2 commits into
Graphify-Labs:v8from
yotamleo-org:fix/2821-render-rationale
Open

yotamleo wants to merge 2 commits into
Graphify-Labs:v8from
yotamleo-org:fix/2821-render-rationale

Conversation

@yotamleo

Copy link
Copy Markdown
Contributor

Fixes #2821. Supersedes #2830, which has been conflicting with v8 since 2026-08-20; this is that change rebased onto current v8, with the original commit and author kept.

What

graph.json stores a rationale on semantic nodes, but query, get_node and explain never printed it. This adds it, opt-in:

  • CLI: graphify query ... --rationale and graphify explain ... --rationale
  • MCP: include_rationale on query_graph and get_node (strict boolean)
  • query appends WHY <text> after the NODE line; explain and get_node print Rationale: <text>
  • Default output is unchanged without the flag.

The text is untrusted corpus/LLM output, so it goes through sanitize_rationale() in graphify/security.py:

  • control characters, C1 (\x80-\x9f), bidi overrides and zero-width characters stripped
  • single-line mode for query (cap 512), multi-line for explain/get_node (cap 2048), explicit ... marker when capped
  • in detail mode, continuation lines are indented so a rationale cannot pose as a sibling field (Degree:, Node:)
  • non-string values are ignored

Changes on top of #2830

The first commit is #2830 rebased onto v8 (conflicts in cli.py, serve.py, tests/test_explain_cli.py, tests/test_serve.py; the new --seed, _resolve_single_node and attrs suffix are preserved). The second commit is review hardening:

  • forged-field and line-separator handling described above
  • the shared _CONTROL_CHAR_RE is left as on v8; the C1 range applies to rationale only, so sanitize_label behaviour does not change
  • --rationale listed in the main help text

Before / after

Fixture: a small graph with rationale on two semantic nodes.

Before (v8), graphify query "halt narrowing token" --graph g.json:

NODE Halt/narrowing needs no token (fail-safe asymmetry) [src=docs/internals/retask-channel.md loc=None community=]

graphify explain "completes-ticket line and Ticket coverage" --graph g.json --rationale prints no Rationale line (the flag is ignored).

After, with --rationale:

NODE Halt/narrowing needs no token (fail-safe asymmetry) [src=docs/internals/retask-channel.md loc=None community=] WHY An attacker who can only make the child do less has a mild DoS, not a compromise; a stood-down worker must not reason a halt away as 'not genuine user input'.
  Rationale: The merge script closes the ticket only on --jira-transition because a default closes multi-PR tickets early (HIMMEL-3059).

Without the flag the output is identical to v8. A node whose rationale contains ESC, BEL and \x9b bytes, a newline and a ~1200-char tail prints with the control bytes stripped; the query WHY is collapsed to one line.

Tests

  • New tests in tests/test_security.py, tests/test_query_cli.py, tests/test_explain_cli.py, tests/test_serve.py; the flag/kwarg does not exist on v8, so they fail there.
  • pytest tests/: 8418 passed, 14 skipped, 1 deselected.
  • The deselected test, tests/test_extract.py::test_collect_files_from_dir, is an environment artifact, not related to this diff: it fails only when the checkout sits under a hidden directory (here .worktrees/), because collect_files skips hidden path components. It passes on a clean v8 copy outside a hidden path, and this PR does not touch extract.py.
  • ruff check . clean. pyright graphify reports 498 errors on both v8 and this branch (no new ones).

hopstreax and others added 2 commits October 20, 2026 02:51
…haracters (Graphify-Labs#2821)

Review follow-ups on the rebased Graphify-Labs#2830:
- indent continuation lines in explain/get_node so a rationale cannot pose as a sibling field
- strip bidi/zero-width characters; treat U+2028, U+2029 and NEL as line breaks
- keep the shared control-char regex unchanged; the C1 range applies to rationale only
- ignore non-string rationale values; clamp a negative max_chars
- list --rationale in the main help text

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yotamleo
yotamleo requested a review from safishamsi as a code owner October 20, 2026 01:10
@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Oct 20, 2026
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @yotamleo. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.

Not checked: tests were not run; formal verification proved 0 of 6 changed function(s) (3 sampled, not proven, 3 not verified).

Formal verification. PR-changed functions: 3/6 verified (0 proven, 3 may-equivalent, 0 distinguished) · 3 not verified (3 vacuous).

Not verified on this run: dispatch\_command (vacuous: never exercised), \_build\_server (vacuous: never exercised), \_render\_query (vacuous: never exercised).


Graphify review — findings

Adds an opt-in --rationale flag to graphify query and graphify explain that shows a node's stored rationale. Query appends it as a single-line WHY suffix capped at 512 chars, and explain prints an indented Rationale: block that keeps paragraphs up to 2048 chars; without the flag, output is unchanged. Introduces sanitize_rationale and indent_continuation, which strip control, bidi and zero-width characters, mark truncation with ..., and indent continuation lines so multi-line text can't pose as a sibling field, while sanitize_label keeps its existing behaviour. Threads include_rationale through _subgraph_to_text and _query_graph_text into the rendered output and documents the flag in the help text.

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2377 functions depend on the 689 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: to_json() — 87 callers, 11 callees
  • worse: dispatch_command() — 5 callers, 136 callees
  • worse: main() — 111 callers, 3 callees
  • worse: _query_graph_text() — 49 callers, 5 callees
  • worse: _subgraph_to_text() — 31 callers, 4 callees
  • worse: _build_server() — 6 callers, 19 callees

Verification — 2377 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1719 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

96 of 432 test file(s) selected (22%) via static blast radius.

  • tests/test_affected_cli.py — impact
  • tests/test_agents_platform.py — impact
  • tests/test_atomic_writes.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_benchmark_real_query.py — impact
  • tests/test_build.py — impact
  • tests/test_build_located_semantic_identity.py — impact
  • tests/test_build_merge_dedup_scope.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_callflow_html.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cloud_cta.py — impact
  • tests/test_codebuddy.py — impact
  • tests/test_community_labels_skill.py — impact
  • tests/test_compact_graph_json.py — impact
  • tests/test_confidence.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_dedup.py — impact
  • tests/test_dedup_shrink_refuses_force_write.py — impact
  • tests/test_devin.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_explain_cli.py — impact, changed-test
  • tests/test_explain_same_file_tie.py — impact
  • tests/test_export.py — impact
  • tests/test_export_control_characters.py — impact
  • tests/test_export_direction.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_global_graph.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_god_nodes_cli.py — impact
  • tests/test_god_nodes_exclude_hubs.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/test_hook_guard_token_match.py — impact
  • tests/test_hook_out_of_project_paths.py — impact
  • tests/test_hook_strict.py — impact
  • tests/test_html_script_breakout.py — impact
  • tests/test_html_theme.py — impact
  • tests/test_hyperedge_roundtrip.py — impact
  • tests/test_hypergraph.py — impact
  • tests/test_incomplete_build_guard.py — impact
  • tests/test_ingest.py — impact
  • tests/test_ingest_url_type.py — impact
  • … and 46 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Docs that may be stale (advisory)

Formal verification

No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).

The verifier ran both versions of \_run\_cli on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 40 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_build\_server.

The verifier did not have enough to check \_build\_server, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 40 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly ValueError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).

The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_render\_query.

The verifier did not have enough to check \_render\_query, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 223 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in \_subgraph\_to\_text (not a proof).

The verifier ran both versions of \_subgraph\_to\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 1 grounded finding(s) anchored inline below; 5 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/serve.py


def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str:
def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None, include_rationale: bool = False) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _subgraph_to_text()

31 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, no change: the 31 callers are pre-existing. This PR only adds an optional keyword argument (include_rationale, default False) to _subgraph_to_text and forwards it, so no caller changes and default output is identical.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rationale attribute is never rendered by query / get_node — the node that answers is returned label-only

2 participants