Skip to content

Commit 9c8d705

Browse files
feat(krl): DB-6 Phase A — explain keyword + structured query plan (#34) (#86)
## Summary Closes the **Phase A** gap on #34. Before this, EXPLAIN was reachable only over HTTP with raw SQL; the KRL surface the issue actually specifies did not exist — `explain` appeared in neither the lexer nor the parser, and there was no selectivity model at all, not even the stub the criteria permit. ### What's added - **`explain` is a reserved word and a statement prefix**: `explain from … | filter … | take …`. - It parses to **`KRLExplainStmt`** — deliberately a *distinct statement type* rather than a flag on `KRLQueryStmt`, so every existing consumer of `KRLQueryStmt` still means "this query will actually execute" and cannot be handed a plan where it expected rows. - **`ExplainPlan.jl`** builds the plan shape the acceptance criteria specify — an ordered `scan` followed by one entry per stage, each filter carrying `column`, `comparator`, `value`, `indexed` and a selectivity estimate. Conjunctions are **split into separate filter operations**, which is what makes the DB-7 reorder meaningful later. ``` explain from knots | filter colouring_count_3 = 9 and crossing < 8 | take 5 {"op" => "scan", "table" => "knots"} {"op" => "filter", "column" => "colouring_count_3", "comparator" => "=", "value" => 9, "indexed" => true, "selectivity_estimate" => 0.5, "selectivity_source" => "stub"} {"op" => "filter", "column" => "crossing", "comparator" => "<", "value" => 8, "indexed" => false, "selectivity_estimate" => 0.5, "selectivity_source" => "stub"} {"op" => "take", "n" => 5} ``` ### Honesty properties (each asserted by a test) - **Selectivity is the stub the criteria permit pending DB-3 (#33) — and says so.** Every estimate carries `"selectivity_source" => "stub"`, and `plan_is_costed` returns `false` while any stub remains. That is the guard stopping a future DB-7 from silently optimising against invented numbers. - **`indexed` is driven by a deliberately short explicit column list.** An over-claiming list would make it a fake signal, which is worse than reporting `false`. - **A predicate the plan cannot describe is not mis-described.** Column-to-column comparisons, nested booleans and calls are reported opaquely (`"predicate" => "<expression>"`, no `column` key) rather than given a fabricated column. ## Verification 25 assertions in `server/krl/test/explain_test.jl`, wired into the dependency-free CI step alongside the other pure-Julia suites (so it runs before `Pkg.instantiate` and cannot be masked by a dependency breakage). The existing lexer / parser / sql / seam suites all still pass. ## Scope Phase A only. **Phase B (cost-based reordering) is deliberately not attempted** — it needs real selectivity, which is DB-3 (#33). The plan returned is execution order *as written*, with no reordering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9113da7 commit 9c8d705

7 files changed

Lines changed: 256 additions & 2 deletions

File tree

.github/workflows/krl-verification.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ jobs:
6666
julia --color=yes server/krl/test/parser_test.jl
6767
julia --color=yes server/krl/test/sql_test.jl
6868
julia --color=yes server/krl/test/seam_test.jl
69+
julia --color=yes server/krl/test/explain_test.jl
6970
7071
# server/Project.toml [sources] resolves KnotTheory/Skein/AcceleratorGate
7172
# at server/../../<name>.jl — i.e. one directory above the repo checkout

server/krl/Ast.jl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export KRLNode, KRLStatement, KRLSource, KRLPipeStage, KRLReturnItem,
3636
SortOrder, SortAsc, SortDesc,
3737
EdgeDir, EdgeForward, EdgeBackward, EdgeUndirected,
3838
# program + statements
39-
KRLProgram, KRLQueryStmt, KRLLetStmt, KRLRuleDef, KRLAxiomDef,
39+
KRLProgram, KRLQueryStmt, KRLExplainStmt, KRLLetStmt, KRLRuleDef, KRLAxiomDef,
4040
# query + sources
4141
KRLQuery, KRLSourceKnots, KRLSourceDiagrams, KRLSourceInvariants,
4242
KRLSourceNamed, KRLSourceSubquery,
@@ -123,6 +123,20 @@ struct KRLQueryStmt <: KRLStatement
123123
col::Int
124124
end
125125

126+
"""
127+
KRLExplainStmt(query, line, col)
128+
129+
`explain from … | …` — DB-6 Phase A. Returns the query PLAN rather than
130+
running the query. Deliberately a distinct statement type rather than a flag
131+
on `KRLQueryStmt`, so that every existing consumer of `KRLQueryStmt`
132+
continues to mean "this query will actually execute".
133+
"""
134+
struct KRLExplainStmt <: KRLStatement
135+
query::KRLNode # KRLQuery
136+
line::Int
137+
col::Int
138+
end
139+
126140
"""
127141
KRLLetStmt(name, type_ann, expr, line, col)
128142

server/krl/ExplainPlan.jl

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# DB-6 Phase A — structured query plan for `explain from … | …`.
5+
#
6+
# Produces the shape the DB-6 acceptance criteria specify: an ordered list of
7+
# operations, each tagged with its kind and, for filters, the column, the
8+
# comparator, the literal value, whether the column is indexed, and a
9+
# selectivity estimate.
10+
#
11+
# HONESTY NOTE ON SELECTIVITY
12+
# ---------------------------
13+
# There are no column histograms yet — that is DB-3 (#33). The criteria
14+
# explicitly permit a stub until DB-3 lands, so every estimate here is
15+
# `SELECTIVITY_STUB` and is reported with `"selectivity_source" => "stub"`.
16+
# Nothing downstream may treat these as measured: `plan_is_costed` returns
17+
# false while any estimate is a stub, so a future DB-7 cost-based reorder
18+
# cannot silently optimise against made-up numbers.
19+
20+
# Included directly into the `KRL` module namespace (as Ast.jl / Parser.jl /
21+
# Evaluator.jl are) — not a submodule, so AST types resolve without imports.
22+
23+
"""Placeholder selectivity used until DB-3 (#33) provides real histograms."""
24+
const SELECTIVITY_STUB = 0.5
25+
26+
"""
27+
Columns carrying a secondary index today. Kept deliberately small and
28+
explicit: an over-claiming list would make `indexed` a fake signal, which is
29+
worse than reporting `false`. Extend as DB-3 lands real indexes.
30+
"""
31+
const INDEXED_COLUMNS = Set([
32+
"colouring_count_3",
33+
"colouring_count_5",
34+
"presentation_hash",
35+
"quandle_key",
36+
])
37+
38+
_cmp_symbol(k::Symbol) = get(Dict(
39+
:eq => "=", :neq => "!=", :lt => "<", :lte => "<=",
40+
:gt => ">", :gte => ">=", :iso => "", :path => "~>", :in => "in",
41+
), k, string(k))
42+
43+
_source_name(s::KRLSource) =
44+
s isa KRLSourceKnots ? "knots" :
45+
s isa KRLSourceDiagrams ? "diagrams" :
46+
s isa KRLSourceInvariants ? "invariants" :
47+
s isa KRLSourceNamed ? s.name :
48+
s isa KRLSourceSubquery ? "<subquery>" : "<unknown>"
49+
50+
# Pull (column, comparator, value) out of a comparison predicate when the
51+
# shape is the simple `column <cmp> literal` the plan can describe. Anything
52+
# else (nested boolean, function call, column-to-column) yields `nothing` and
53+
# is reported as an opaque predicate rather than being mis-described.
54+
_literal_value(e::KRLExpr) =
55+
e isa KRLInt ? e.n :
56+
e isa KRLFloat ? e.x :
57+
e isa KRLString ? e.s :
58+
e isa KRLBool ? e.b :
59+
e isa KRLKnotName ? e.name : nothing
60+
61+
function _simple_comparison(e::KRLExpr)
62+
e isa KRLCompare || return nothing
63+
e.left isa KRLVar || return nothing
64+
val = _literal_value(e.right)
65+
val === nothing && return nothing
66+
(e.left.name, _cmp_symbol(e.op), val)
67+
end
68+
69+
# Split a conjunction into its conjuncts so `filter a = 1 and b < 2` plans as
70+
# two filter operations, which is what makes a future reorder meaningful.
71+
function _conjuncts(e::KRLExpr)
72+
e isa KRLAnd && return vcat(_conjuncts(e.left), _conjuncts(e.right))
73+
[e]
74+
end
75+
76+
function _filter_ops(pred::KRLExpr)
77+
ops = Dict{String, Any}[]
78+
for c in _conjuncts(pred)
79+
simple = _simple_comparison(c)
80+
if simple === nothing
81+
push!(ops, Dict{String, Any}(
82+
"op" => "filter",
83+
"predicate" => "<expression>",
84+
"indexed" => false,
85+
"selectivity_estimate" => SELECTIVITY_STUB,
86+
"selectivity_source" => "stub",
87+
))
88+
else
89+
col, cmp, val = simple
90+
push!(ops, Dict{String, Any}(
91+
"op" => "filter",
92+
"column" => col,
93+
"comparator" => cmp,
94+
"value" => val,
95+
"indexed" => col in INDEXED_COLUMNS,
96+
"selectivity_estimate" => SELECTIVITY_STUB,
97+
"selectivity_source" => "stub",
98+
))
99+
end
100+
end
101+
ops
102+
end
103+
104+
"""
105+
explain_plan(q::KRLQuery) -> Vector{Dict{String, Any}}
106+
107+
Ordered plan for `q`: a `scan` of the source followed by one entry per
108+
pipeline stage, with conjoined filters split into separate `filter`
109+
operations.
110+
111+
The order returned is **execution order as written** — no reordering is
112+
performed. Cost-based reordering is DB-7 and requires real selectivity
113+
(DB-3), which is why every estimate here is flagged `"stub"`.
114+
"""
115+
function explain_plan(q::KRLQuery)::Vector{Dict{String, Any}}
116+
plan = Dict{String, Any}[Dict{String, Any}(
117+
"op" => "scan",
118+
"table" => _source_name(q.source),
119+
)]
120+
for stage in q.stages
121+
if stage isa KRLFilterStage
122+
append!(plan, _filter_ops(stage.pred))
123+
elseif stage isa KRLSortStage
124+
push!(plan, Dict{String, Any}(
125+
"op" => "sort",
126+
"keys" => length(stage.items),
127+
))
128+
elseif stage isa KRLTakeStage
129+
push!(plan, Dict{String, Any}("op" => "take", "n" => stage.n))
130+
elseif stage isa KRLSkipStage
131+
push!(plan, Dict{String, Any}("op" => "skip", "n" => stage.n))
132+
else
133+
push!(plan, Dict{String, Any}("op" => _stage_op_name(stage)))
134+
end
135+
end
136+
plan
137+
end
138+
139+
_stage_op_name(s::KRLPipeStage) =
140+
s isa KRLReturnStage ? "return" :
141+
s isa KRLGroupByStage ? "group_by" :
142+
lowercase(replace(string(nameof(typeof(s))), "KRL" => "", "Stage" => ""))
143+
144+
"""
145+
plan_is_costed(plan) -> Bool
146+
147+
`true` only when every selectivity estimate in `plan` came from real
148+
statistics. While DB-3 (#33) is outstanding this is always `false` — the
149+
guard that stops DB-7 optimising against stub numbers.
150+
"""
151+
plan_is_costed(plan::Vector{Dict{String, Any}}) =
152+
!any(get(op, "selectivity_source", "stub") == "stub" for op in plan)

server/krl/KRL.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ include("Lexer.jl")
2828
include("Ast.jl")
2929
include("Parser.jl")
3030
include("SqlFrontend.jl")
31+
include("ExplainPlan.jl")
3132
include("Evaluator.jl")
3233

3334
end # module KRL

server/krl/Lexer.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const TokenKind = Symbol
6262
# ─────────────────────────────────────────────────────────────────────────────
6363

6464
const KRL_KEYWORDS = Set([
65+
# query-level modifiers
66+
"explain",
6567
# pipeline stages
6668
"from", "filter", "sort", "take", "skip", "return",
6769
"group_by", "aggregate",

server/krl/Parser.jl

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,17 @@ function _parse_statement(ps::ParserState)::KRLStatement
189189
return _parse_let_stmt(ps)
190190
end
191191

192+
# explain from … | … (DB-6 Phase A: plan, do not execute)
193+
if _kw(ps, "explain")
194+
_advance!(ps)
195+
et = _peek(ps)
196+
_kw(ps, "from") || throw(KRLParseError(
197+
"expected `from` after `explain`, got $(et.kind)($(repr(et.value)))",
198+
et.line, et.col))
199+
q = _parse_query(ps)
200+
return KRLExplainStmt(q, t.line, t.col)
201+
end
202+
192203
# from … | … (pipeline query)
193204
if _kw(ps, "from")
194205
q = _parse_query(ps)
@@ -197,7 +208,7 @@ function _parse_statement(ps::ParserState)::KRLStatement
197208

198209
t = _peek(ps)
199210
throw(KRLParseError(
200-
"expected statement (from/let/rule/axiom), got $(t.kind)($(repr(t.value)))",
211+
"expected statement (from/explain/let/rule/axiom), got $(t.kind)($(repr(t.value)))",
201212
t.line, t.col))
202213
end
203214

server/krl/test/explain_test.jl

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# DB-6 Phase A — `explain from … | …` produces a structured plan.
5+
6+
using Test
7+
include(joinpath(@__DIR__, "..", "KRL.jl"))
8+
using .KRL: parse_krl, explain_plan, plan_is_costed, SELECTIVITY_STUB,
9+
KRLExplainStmt, KRLQueryStmt, KRLParseError
10+
11+
@testset "DB-6: explain is a distinct statement, not a query" begin
12+
prog = parse_krl("explain from knots | filter crossing < 8")
13+
@test length(prog.statements) == 1
14+
@test prog.statements[1] isa KRLExplainStmt
15+
# A plain query must NOT become an explain — the two are distinguishable
16+
# so no existing consumer can be handed a plan where it expected rows.
17+
@test parse_krl("from knots | filter crossing < 8").statements[1] isa KRLQueryStmt
18+
end
19+
20+
@testset "DB-6: explain requires a query after it" begin
21+
@test_throws KRLParseError parse_krl("explain")
22+
@test_throws KRLParseError parse_krl("explain take 5")
23+
end
24+
25+
@testset "DB-6: plan shape matches the acceptance criteria" begin
26+
prog = parse_krl(
27+
"explain from knots | filter colouring_count_3 = 9 and crossing < 8 | take 5")
28+
plan = explain_plan(prog.statements[1].query)
29+
30+
@test plan[1]["op"] == "scan"
31+
@test plan[1]["table"] == "knots"
32+
33+
# A conjunction becomes SEPARATE filter operations — that is what makes a
34+
# future cost-based reorder (DB-7) meaningful.
35+
@test plan[2]["op"] == "filter"
36+
@test plan[2]["column"] == "colouring_count_3"
37+
@test plan[2]["comparator"] == "="
38+
@test plan[2]["value"] == 9
39+
@test plan[2]["indexed"] == true # carries a secondary index
40+
41+
@test plan[3]["column"] == "crossing"
42+
@test plan[3]["comparator"] == "<"
43+
@test plan[3]["value"] == 8
44+
@test plan[3]["indexed"] == false # does not
45+
46+
@test plan[4]["op"] == "take"
47+
@test plan[4]["n"] == 5
48+
end
49+
50+
@testset "DB-6: selectivity is honestly labelled a stub until DB-3" begin
51+
plan = explain_plan(
52+
parse_krl("explain from knots | filter crossing < 8").statements[1].query)
53+
filt = plan[2]
54+
@test filt["selectivity_estimate"] == SELECTIVITY_STUB
55+
@test filt["selectivity_source"] == "stub"
56+
# The guard that stops DB-7 optimising against invented numbers. This must
57+
# stay false until real histograms land (#33) — if it ever returns true
58+
# while estimates are stubs, the cost model is lying.
59+
@test plan_is_costed(plan) == false
60+
end
61+
62+
@testset "DB-6: a predicate the plan cannot describe is not mis-described" begin
63+
# Column-to-column comparison is not a simple `column <cmp> literal`, so
64+
# it must be reported opaquely rather than given a fabricated column.
65+
plan = explain_plan(
66+
parse_krl("explain from knots | filter crossing < genus").statements[1].query)
67+
@test plan[2]["op"] == "filter"
68+
@test plan[2]["predicate"] == "<expression>"
69+
@test !haskey(plan[2], "column")
70+
@test plan[2]["indexed"] == false
71+
end
72+
73+
println("krl-explain-tests-ok")

0 commit comments

Comments
 (0)