Skip to content

feat-015: read-only graph query surface (ckg query --graph / ckg_query)#156

Merged
kjoshi07 merged 11 commits into
mainfrom
feat/015-ckg-query
Jul 8, 2026
Merged

feat-015: read-only graph query surface (ckg query --graph / ckg_query)#156
kjoshi07 merged 11 commits into
mainfrom
feat/015-ckg-query

Conversation

@kjoshi07

@kjoshi07 kjoshi07 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Graduates FA-003 into feat-015 (target 0.6.4): a read-only, guard-railed structural query surface over the CKG — the escape hatch for questions no typed verb covers (e.g. "classes tagged Repository with no inbound CALLS", "interfaces implemented by >5 classes"). Complements ckg_search (semantic discovery) with exact structural interrogation.

The one idea

Caller text is never executed. A bounded Cypher-subset string is parsed into our own validated AST (the single trust boundary), then either compiled to native Cypher (Kuzu, Neo4j) or interpreted over the GraphStore ABC (SurrealDB + any backend without a native query language). One AST, two execution strategies, identical results — proven by a shared conformance suite run live against all three backends.

What ships

  • CLI: ckg query --graph '<cypher>' with --format table|json, --limit, and ckg query --schema.
  • MCP: a ckg_query tool, capability-gated (registered only when the backend is query-capable and query.allow_in_mcp is on); results carry the staleness + query_lang_version envelope.
  • Language: MATCH patterns (directions, bounded var-length [:CALLS*1..3]), WHERE (comparisons, AND/OR/NOT, IN, STARTS/ENDS WITH, CONTAINS, pattern-existence), RETURN (projections, aliases, DISTINCT, count/min/max/avg/collect), ORDER BY/SKIP/LIMIT. Writes/DDL, CALL, unbounded paths, and un-joined Cartesian products are rejected with a clear reason.
  • Bounds, no silent caps: max_rows/timeout_ms/max_expansions enforced on every backend, reported via truncated + stopped_reason. New query: config block; ckg status reports the language version + capability.

Backends (all verified live)

Backend Strategy Conformance
Kuzu (default, embedded) compiled → Cypher ✅ always-on CI
Neo4j compiled → Cypher (read-only txn) ✅ live container
SurrealDB interpreted over the ABC ✅ live container

All three return identical rows for the shared query set (parity), a runaway query trips the row/expansion caps (bounded), and writes can't reach execution (read-only).

Built in 7 chunks

AST+parser+validator → Cypher compiler + Kuzu → Neo4j → AST interpreter + SurrealDB → CLI → MCP tool + capability gate → config + docs (guide 13).

Notable design decision: SurrealDB models edges as a document table (no native graph traversal), so instead of a fragile SurrealQL translator it uses a portable AST interpreter over the GraphStore ABC — which makes every backend query-capable for free. See the spec's Implementation-status section for all (deliberate, documented) deviations.

Also included

fix(resolver) [BUG-CARRIED] — surfaced during end-to-end validation: a base class imported via an absolute path and re-exported through a package __init__ (e.g. class KuzuGraphStore(GraphStore)) produced no INHERITS edge. Now resolved via a package re-export namespace (fixpoint). On the real package: INHERITS 11→17, CALLS 373→395 — improves recall for every consumer.

Verification

  • 1093 passed, 69 skipped, 94.34% coverage; ruff + mypy --strict clean.
  • Real end-to-end: indexed this project's own core+store (30 files, 520 nodes) and ran the headline queries via CLI and the ckg_query tool — inheritance, aggregates+order+truncation, string preds, pattern-existence, var-length, JSON, rejections — all correct.

🤖 Generated with Claude Code

kjoshi07 and others added 11 commits July 8, 2026 11:13
Graduate the read-only graph query surface (FA-003) into feat-015, targeting
0.6.4. Add the feature spec and the accepted design doc; register both in the
trackers.

The design resolves the caller-facing surface (Cypher-subset text) and the
internal trust boundary (validated AST -> per-backend compile). Hardened for
extensibility per review: polymorphic Compiler classes + singledispatch (not a
dialect flag), capability tiers (not intersection-forever), a shared
bounded-cursor making max_expansions real+tested on every backend (not
approximated), a capability-driven tool registry, and a reusable cli formatter.
Scope: all three backends (Kuzu + Neo4j + SurrealDB) query-capable at ship.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First chunk of the read-only graph query surface: the framework-free
store/query/ package that turns caller text into a validated AST. No backend
execution yet (chunk 2).

- GRAMMAR.md: versioned EBNF (v1.0) of the accepted Cypher subset; the source
  of truth the parser mirrors. Write verbs / CALL / WITH have no production.
- ast.py: frozen query AST (the single trust boundary).
- parser.py: hand-written tokenizer + recursive-descent parser, no new
  dependency. Syntax only; labels/kinds kept as raw strings.
- validator.py: two-phase gate — (1) vocabulary (labels in NodeKind, rel types
  in EdgeKind, curated properties or attrs.*, bound-variable + Cartesian-product
  checks) and (2) capability tiers against the target backend.
- schema.py: curated node-property catalogue mapped to the shared _rowmap
  columns (f.path -> sym_path, span -> span_start/end, provenance columns), so
  properties resolve identically on every backend; describe_schema() for
  --schema. QUERY_LANG_VERSION reported by ckg status (chunk 7).
- capability.py: capability tiers, the optional QueryCapable protocol, and
  QuerySettings / ResultTable used by execution (chunk 2).
- Extend the ADR-0001 layering test to the new subpackage.

Resolved: a comparison's RHS is a parameterized literal (no property-to-property
compare), so WHERE cannot join disconnected patterns — multi-pattern MATCH must
be connected by shared variables.

72 unit tests. Full gate green: 1023 passed, 94.62% coverage; ruff + mypy
--strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first end-to-end vertical slice of the read-only query surface: a validated
AST now compiles to openCypher and runs against the default (Kuzu) backend under
enforced bounds, returning a normalized columnar result.

- compile_base.py: Compiler ABC + CompiledQuery + ParamAllocator. A class per
  dialect, not a dialect flag — a new dialect is a subclass, a new construct is
  one added emit-arm.
- compile_cypher.py: CypherCompiler (+ Kuzu/Neo4j subclasses) over the single
  generic-table schema — (f:Function) -> (f:CkgNode {kind:'Function'}), a logical
  property -> its physical column (f.path -> f.sym_path) via the curated
  catalogue, all literals parameterized. Verified against a real embedded Kuzu.
- execute.py: the shared bounded driver. pull_bounded is pure and clock-injected
  (row cap + expansion backstop + soft timeout -> partial result); run_bounded
  runs the DB work on one worker thread with a hard wait_for backstop. Bounds are
  a real, tested guarantee on every backend, not approximated where hard.
- KuzuGraphStore.run_query + QueryCapable class attrs (dialect=kuzu,
  caps=core+agg.collect, read_only_execution=False — the AST gate is the
  read-only guarantee for the embedded backend).
- Store.query_graph / query_enabled / query_capabilities; CodeGraph.query_graph
  / describe_schema / query_enabled / query_capabilities.
- conformance.py: QueryConformance — a 3-part contract (result parity, bounded
  execution, read-only) every query-capable backend must pass; Kuzu subclass is
  the always-on CI gate.

attrs.* is gated behind an optional attrs.access capability that no v1 backend
advertises (Kuzu/Neo4j store attrs as a JSON string, which native Cypher cannot
destructure without a workaround that breaks under aggregation) — an honest
deferral through the capability seam. The curated columns cover the real
structural-query use cases.

Deviations (for the spec at merge): the compiler visitor uses match statements,
not functools.singledispatchmethod (mypy-clean, same additive property);
QueryConformance lives in store/query/, not core/ (core must not import store).

Gate: 1050 passed, 45 skipped, 94.68% coverage; ruff + mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The power-user surface for the read-only query engine. The natural-language
retrieval path (ckg query "<text>") is unchanged; --graph selects the structural
surface.

- cli_format.py: a reusable render_table / render_json helper (introduced here,
  adoptable by other verbs later — not inline in the handler).
- query subcommand: --graph '<cypher-subset>' runs a structural query; --schema
  prints the queryable vocabulary (node/edge kinds + properties); --format
  {table,json}; --limit caps rows (clamped to the server max). QueryError /
  QueryDisabled -> stderr + exit 2 (the structured "why", not a traceback).

Verified live: ckg index a repo, then ckg query --graph 'MATCH (c:Class) RETURN
c.name' -> aligned table; --format json -> columns/rows + truncation envelope;
--schema -> vocabulary.

Gate: full suite green (1064 passed, 94.72% cov); ruff + mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent surface for the read-only query engine, plus the honest tool-gating
seam.

- CkgQuery tool (QueryInput {query, limit}); engine.query_graph folds the
  staleness envelope + tool_api_version + query_lang_version and returns a
  STRUCTURED error rather than raising into the tool layer. ckg_status now
  reports query.{enabled, lang_version, capabilities}.
- Capability-driven registration (not always-register-then-error): _tools_for
  filters ALL_TOOLS by `tool.requires <= backend capabilities`, so ckg_query is
  present exactly when the backend is query-capable and cleanly absent otherwise.
  _store_capabilities checks the graph DRIVER CLASS for query_dialect (no index
  opened); federation offers ckg_query only when every member is query-capable.
  This is a reusable gate for future capability-scoped tools.
- test_schemas is now profile-parameterized: the enabled profile includes
  ckg_query, the disabled profile excludes it — drift on either path fails CI.

Deviation (for the spec at merge): ckg_query v1 has no `params` argument (the
design's open-Q1 leaned yes). The grammar inlines and parameterizes literals
internally and exposes no $placeholder syntax, so a caller-facing params map has
no binding site in v1 — honest to omit it.

Gate: 1064 passed, 45 skipped, 94.72% coverage; ruff + mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The second query-capable backend, proving the Cypher compiler is portable.

- execute.py: pull_bounded_async + run_bounded_async — the async twin of the
  bounded pull, for backends whose driver is async (Neo4j, and SurrealDB next).
  Same row/expansion/timeout policy; hard wait_for backstop -> GuardrailError.
- Neo4jGraphStore.run_query: compiles via Neo4jCypherCompiler (the shared Cypher
  body — Neo4j uses the identical single-table schema, so no dialect deltas were
  needed) and executes inside a genuine READ transaction (execute_read), so
  read-only is enforced by the session (gate #2) on top of the AST gate (gate #1).
  QueryCapable: dialect=neo4j, caps=core+agg.collect, read_only_execution=True.
- Neo4j QueryConformance subclass (env-gated on CKG_NEO4J_URI).

Verified against a live Neo4j 5 container: all 11 conformance tests pass — the
same query set returns the same rows as Kuzu (result parity), the runaway query
trips the row/expansion caps, and writes are rejected. Two of three backends now
proven result-identical.

Unit gate green; ruff + mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The third query-capable backend — and a universal query path. SurrealDB models
edges as a document table (src/dst fields) with no native graph traversal to
compile to, so rather than a fragile SurrealQL translator it *interprets* the
query.

- interpret.py: InterpretingQueryEngine evaluates a validated QueryAst using only
  the locked GraphStore ABC read methods (query/adjacent/get). This makes query
  support universal — any GraphStore is query-capable for free — and it is
  inherently read-only (no write path through the ABC). Same row/expansion/
  timeout bounds as the compiled path, and it passes the SAME QueryConformance
  suite, so results are identical to the canonical rows. "Compile where the
  backend has a native query language (Kuzu/Neo4j); interpret elsewhere."
- SurrealGraphStore.run_query delegates to the interpreter (dialect=interpreted,
  read_only_execution=True — the interpreter cannot write).
- Interpreter coverage without a server: test_interpret_conformance runs it over
  embedded Kuzu (always-on CI gate), plus test_interpret unit tests for the
  branches the parity suite doesn't hit (avg/min/max/collect, DISTINCT, ORDER BY
  a non-projected property, attrs access, timeout backstop, node_property).
- Live: SurrealDB QueryConformance (env-gated) — 12 tests pass against a real
  SurrealDB 2 container; identical rows to Kuzu and Neo4j.

Parser bugfix (shared, both paths): CONTAINS is both the string operator and an
EdgeKind, so the tokenizer made it a keyword and [:CONTAINS] failed to parse.
Added _name() so labels/relationship types accept reserved words; locked across
all three backends with a File-[:CONTAINS]->Class conformance test.

All three backends — Kuzu (compiled), Neo4j (compiled), SurrealDB (interpreted) —
now return identical rows. Gate: 1084 passed, 94.33% coverage; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final chunk — make the query surface configurable and documented.

- QueryConfig(_Block, KEY="query"): enabled / max_rows / timeout_ms /
  max_expansions / allow_in_mcp, with to_settings(limit). Auto-discovered by
  block_keys(); read framework-free (ADR-0001).
- Wired through the surfaces: the CLI (`_query_graph`) and engine
  (`query_graph`) build QuerySettings from the block and refuse when
  enabled=false; the serve capability gate offers ckg_query only when the
  backend is query-capable AND enabled AND allow_in_mcp.
- Docs: guide 13 (ad-hoc structural queries) + guides index; README tool list
  now notes ckg_query; CHANGELOG [Unreleased] entry; feat-015 Implementation-
  status section recording the (deliberate, documented) deviations — chiefly the
  portable AST interpreter for SurrealDB in place of a native SurrealQL compiler.

feat-015 is now complete across all 7 chunks. Gate: 1091 passed, 69 skipped,
94.33% coverage; ruff + mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…UG-CARRIED feat-015]

Surfaced during feat-015 end-to-end validation: `class KuzuGraphStore(GraphStore)`
produced no INHERITS edge. `GraphStore` is imported `from agentforge_graph.core
import GraphStore` but *defined* in `core.contracts` and only re-exported by
`core/__init__.py` — so it was absent from the package module's def-`exports`,
the base stayed unresolved, and no edge was emitted.

Fix: compute a per-package `reexports` namespace from each `pkg/__init__.py`'s own
`from .sub import Name` imports (resolved against `exports` via a fixpoint, so a
re-export chained through several `__init__` files still resolves), and consult it
when binding `from pkg import Name`. So an absolute import of a re-exported symbol
links to its real definition.

On this project's own core+store package: INHERITS 11→17 (all GraphStore /
VectorStore subclasses now link to their ABC), CALLS 373→395 (functions imported
via a package re-export and called by name now resolve too). Improves
INHERITS/CALLS recall for every consumer, not only the query surface.

2 new tests (absolute + relative re-export). Gate: 1093 passed, 94.34% coverage;
ruff + mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Formatting only — the feat-015 edits to CodeGraph.query_graph and the config
tests weren't run through `ruff format`. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
neo4j's session.execute_read returns Any; assign to a typed local so mypy
2.1.0's no-any-return is satisfied. No behaviour change. (A stale local mypy
incremental cache masked this; a --no-incremental run reproduces CI.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kjoshi07 kjoshi07 merged commit 4d79ebd into main Jul 8, 2026
3 checks passed
@kjoshi07 kjoshi07 deleted the feat/015-ckg-query branch July 8, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant