Files
rdbms-playground/docs/adr/0058-clause-concept-hints.md
claude@clouddev1 6d4364666a
ci / gate (pull_request) Successful in 2m6s
ci / manifests (pull_request) Successful in 3s
docs(adr): finalize ADR-0058 number for clause-concept-hints
Rename the placeholder draft to its reserved number (0058, allocated
reserve-first via the new dev-workflow flow), drop the ADR-XXXX
placeholders, and add the README index row.
2026-06-23 20:09:36 +00:00

463 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0058: Clause-concept hints — a `hint.concept.*` layer surfaced by cursor position (issue #37)
## Status
Accepted — **implemented 2026-06-23** on branch `feat/clause-concept-hints`
(pending merge). Number **0058**, reserved up front via the reserve-first
flow (ADR-0059) rather than the older placeholder-until-merge default —
this ADR is the first to carry a number stable from creation. Extends
**ADR-0053** (contextual `hint`, D3/D4) additively; does not supersede it.
Closes the deferred extension tracked as Gitea issue **#37**.
Implementation: `Node::Concept { topic, inner }` (`dsl/grammar/mod.rs`) +
the span-recording driver arm (`dsl/walker/driver.rs`) +
`WalkContext::concept_spans` / `ConceptSpan` (`dsl/walker/context.rs`);
the `concept_topic_at_cursor` resolver (`dsl/walker/mod.rs`); 10
`Node::Concept` wrappers across `shared.rs` / `ddl.rs` /
`sql_create_table.rs` (one shared `REFERENTIAL_CLAUSES`, two extracted
cardinality sub-`Seq`s); the mode-keyed `emit_tier3_block` +
`note_hint_for_input` layering (`app.rs`); the seven `hint.concept.*`
blocks + `hint.block.concept_heading` (`strings/en-US.yaml`) with
`keys.rs` declarations; and the test set (14 resolver unit tests, 3
comprehensiveness gates incl. the recursive `Node::Concept` visitor, 3 F1
integration tests, the `hint_block_with_concept` snapshot). Suite green
across all tiers; `fmt`/`clippy` clean.
Revised after a `/runda` review (2026-06-22) that grounded the mechanism
against the code and caught two design gaps: the clause mapping (D4)
covered only the advanced-SQL constraint grammar and missed the
simple-mode constraint suffix (ADR-0029); and the single-`example`
content model collided with ADR-0053 D6's mode-correct-example rule for
topics reachable in both modes. Both are resolved here — D4 now maps both
grammars, and D5 adopts **mode-keyed examples** (user decision,
2026-06-22).
References ADR-0053 (the three-tier `hint` model + the `hint.cmd.*` /
`hint.err.*` corpus), ADR-0024 (the unified grammar/walker, `HintMode`-
per-node, `MatchedPath` spans), ADR-0022 (ambient tier-2 typing
assistance), ADR-0013/0045 (the relationship + `m:n` grammar), ADR-0035
(advanced-mode `CREATE TABLE` constraint grammar).
## Context
ADR-0053 delivers tier-3 teaching hints at **per-form** granularity: one
`hint.cmd.<form>` block (`what` / `example` / `concept`) per command form,
surfaced by F1 on the live input. Position-awareness *within* a form is
owned by tier-2 (the ambient candidate list / slot prose, ADR-0022).
During ADR-0053 Phase B we identified a third teaching point the per-form
model does not serve: **concepts that live inside a clause**, where a
learner may want teaching deeper than tier-2's candidate list but narrower
than the whole-form block. The canonical examples are the most
load-bearing relational concepts in the tool:
- the **referential actions** clause — `… on delete ⟨cascade | set null |
restrict | no action⟩` (and `on update`);
- the **cardinality** marker — `1:n` (one parent, many children) and
`m:n` (many-to-many, auto-junction per ADR-0045);
- the **primary-key** declaration — `with pk(…)` in simple mode and the
`primary key` constraint in `create table (…)`;
- the **column-constraint slots** in `create table (…)` — `unique`,
`check (…)`, `foreign key` / `references`.
A student parked in one of these clauses is, by definition, looking at the
exact relational concept that clause embodies. ADR-0053 explicitly
deferred this ("clause-concept hints", issue #37) and recorded that the
per-form keying does **not** lock it out: a later `hint.concept.<topic>`
namespace can be surfaced when the cursor sits in a recognized clause,
layered on top of the per-form block.
This ADR is that follow-up. It is **pedagogy-led, not metrics-led** — we
do not expect to accumulate usage statistics about which clauses learners
pause at, so the clause set is chosen by relational-teaching value, not
data.
### Two user decisions taken up front (2026-06-22)
The issue left two scope questions open. Both were decided by the user
before design:
1. **Detection precision: "anywhere inside the clause."** The hint fires
whenever the cursor sits *within* a recognized clause's span —
including parked inside already-typed text (e.g. `add 1:n
relation|ship …`) — not only at the slot boundary where the next token
is about to be typed.
2. **Clause scope: all four families** — referential actions, the
create-table constraint slots, `with pk`, and `1:n` / `m:n`
cardinality.
### Why the existing `HintMode`/`pending_hint_mode` mechanism is not enough
Tier-2 slot prose is driven by `Node::Hinted { mode, inner }`: entering
the node sets `ctx.pending_hint_mode`, and the resolver reads it at the
end of the cursor-trimmed walk (`hint_resolution_at_input_in_mode`,
`src/dsl/walker/mod.rs:107`). Crucially, the driver **clears
`pending_hint_mode` on every successful match** (`src/dsl/walker/
driver.rs:154`): it records only "the Hinted slot the cursor is *about to
fill*", and the moment the slot's content matches, the signal is gone.
That is exactly the *boundary* semantics tier-2 wants — and exactly the
opposite of "anywhere inside the clause". The instant the learner finishes
typing `cascade`, `pending_hint_mode` clears, so a cursor parked on the
finished `on delete cascade` would see nothing. Decision (1) above
therefore needs a **span-based** mechanism that survives the clause being
fully matched, not the single-slot `pending_hint_mode`.
## Decision
### D1 — A span-recording grammar wrapper, `Node::Concept`
Add a new grammar combinator, sibling to `Node::Hinted`:
```rust
Node::Concept { topic: &'static str, inner: &'static Node }
```
It is a pass-through for matching (it walks `inner` and returns `inner`'s
result verbatim — it never changes what parses), but as a side effect the
driver records the **byte span the node covered** into a new context
field:
```rust
// WalkContext
pub concept_spans: Vec<ConceptSpan>, // { topic: &'static str, start: usize, end: usize }
```
- `start` = the first non-whitespace byte the node began matching at
(post `skip_whitespace`, matching the driver's existing convention).
- `end` = the byte position the node's walk reached — the end of the
matched span on a full match, or the stop position on an incomplete /
failed inner. This makes the span the real extent of the clause as typed
so far.
Unlike `pending_hint_mode`, `concept_spans` is **append-only across the
whole walk** and is never cleared on match — so a fully-typed clause keeps
its span, satisfying decision (1).
**Precise push rule** (grounded in `NodeWalkResult`,
`src/dsl/walker/driver.rs:97`): walk `inner`, then push `{ topic, start,
end }` iff the inner result is one of `Matched { end }` (end = `end`),
`Incomplete { position }`, or `Failed { position }` (end = `position`) —
i.e. the node **committed** (consumed at least its first token, or ran out
mid-clause). On `NoMatch { .. }` (the node never engaged — e.g. a `Choice`
tried this branch and it didn't apply) push **nothing**. This is the
`NodeWalkResult`-level analogue of the driver's existing
`pending_hint_mode` clear-on-match leak-avoidance, and it means a failed
`Choice` branch leaves no stale span.
`Node::Concept` is **orthogonal to `Node::Hinted`** — a clause that wants
both a tier-2 slot prose *and* a tier-3 concept span nests them (`Concept{
… inner: Hinted{ … } }`). Neither changes the parse.
### D2 — Cursor → topic resolution: innermost containing span
A new resolver:
```rust
pub fn concept_topic_at_cursor(
input: &str, cursor: usize,
schema: Option<&SchemaCache>, mode: Mode,
) -> Option<&'static str>
```
walks the **full** input buffer with `WalkBound::EndOfInput` (the dormant
`WalkBound::Position` path is *not* used — see D3 rationale), collects
`ctx.concept_spans`, and returns the `topic` of the **innermost span
containing `cursor`**. "Innermost" = the containing span with the latest
`start` (equivalently, the narrowest), so when a `references` clause
(constraint slot) wraps a referential-actions clause, a cursor on `on
delete` resolves to the more specific `referential_actions`, while a
cursor on `references Parent` resolves to `foreign_key`.
Containment is inclusive of `start` and `end` (`start <= cursor <= end`),
so the boundary case — cursor *at* the point of entering the clause —
also resolves, meaning this subsumes the simpler boundary detection for
free.
The `cursor` passed in is the one **already computed by `feedback_view()`**
(`src/app.rs:3204` returns `(view, cursor, _off)`), which strips the `:`
one-shot sigil and adjusts the cursor offset to index into the stripped
`view`. The concept resolver walks that same `view`, so the `:`-strip is
handled with no extra offset arithmetic.
### D3 — Why full-buffer span-containment, not `WalkBound::Position`
`WalkBound::Position(cursor)` (defined, `#[allow(dead_code)]`,
`src/dsl/walker/outcome.rs:26`) slices the source at the cursor and walks
the prefix. Two reasons it is the wrong tool here:
- **It discards text after the cursor.** A cursor parked early in a
complete command (`add 1:n| relationship from … on delete cascade`)
would truncate to `add 1:` and lose the parsed structure that tells us
we are inside a well-formed relationship command. Full-buffer walking
keeps the whole parse; the cursor is used *only* for span containment.
- **It still rides `pending_hint_mode`'s clear-on-match**, which D1
already establishes is unsuitable for inside-clause detection.
Span-recording over a full `EndOfInput` walk needs no new walk-bound
behaviour and leaves the dormant `Position` path untouched.
### D4 — The clause set and their topics (both grammars)
**Seven** `hint.concept.<topic>` blocks, placed via `Node::Concept`
wrappers across **both** the simple-mode (`grammar/ddl.rs`, ADR-0029) and
advanced-mode (`grammar/sql_create_table.rs`, ADR-0035) grammars. The
codebase has **two distinct constraint grammars** — simple-mode `create
table` column constraints (`COLUMN_CONSTRAINT_SUFFIX`, `ddl.rs:1082`:
`not null` / `unique` / `default` / `check`; PK is `with pk`, FK is a
relationship) and the advanced-SQL constraint set (`COL_CONSTRAINT_CHOICES`,
`sql_create_table.rs`: adds `primary key` / `references`). A topic
reachable in both modes is wrapped in **both** grammars (or once, if the
node is physically shared — e.g. `REFERENTIAL_CLAUSES` in `shared.rs` is
reused by the relationship command *and* the advanced `references` clause).
| Topic | Modes | Grammar node(s) to wrap |
|---|---|---|
| `referential_actions` | both | `REFERENTIAL_CLAUSES` (`shared.rs` — one wrapper, reused in both) |
| `cardinality_one_to_many` | simple only | a new `CARDINALITY_1N` sub-`Seq` extracted from `ADD_RELATIONSHIP_NODES` (`ddl.rs:479`) |
| `cardinality_many_to_many` | simple only | a new `CARDINALITY_MN` sub-`Seq` extracted from `CREATE_M2N` head (`ddl.rs`) |
| `primary_key` | both | `WITH_PK` (`ddl.rs`, simple) + the `primary key` constraint node (`sql_create_table.rs`, advanced) |
| `unique` | both | the `unique` constraint node in `ddl.rs` (`UNIQUE_CONSTRAINT`, `:1051`) + in `sql_create_table.rs` |
| `check` | both | the `check (…)` constraint node in `ddl.rs` (`:1064`) + in `sql_create_table.rs` |
| `foreign_key` | advanced only | `REFERENCES_CLAUSE` (`sql_create_table.rs:206`) |
If a `unique` / `check` node turns out to be physically shared between the
two grammars (as `REFERENTIAL_CLAUSES` is), one wrapper suffices — the
mode-keyed example (D5) is selected by the **live mode** at F1 time, not by
which grammar node matched, so wrapper-sharing never affects correctness.
The exact wrapper count is pinned during implementation; the *topic* set is
the seven above.
**Nesting is intentional.** `foreign_key`'s `REFERENCES_CLAUSE` contains
`referential_actions`' `REFERENTIAL_CLAUSES`, so a cursor on `references
Parent` resolves to `foreign_key` and a cursor on the inner `on delete`
resolves to `referential_actions` — D2's innermost-wins handles it.
**Deliberate omissions** (faithful to issue #37's explicit list of
"primary key | unique | check | foreign key"): the **`not null`** and
**`default`** column constraints get **no** concept block in v1. They are
mechanical rather than the headline relational concepts #37 targets;
adding them later is purely additive (a wrapper + a block + a coverage
entry).
Cardinality is **split** (`_one_to_many` vs `_many_to_many`) rather than a
single "cardinality" block: the two are pedagogically distinct — `m:n`
auto-creates a junction table (ADR-0045) — the cursor is always
unambiguously in one or the other, and both are **simple-mode-only**
(`1:n`/`m:n` are app syntax, not SQL), so each carries a single plain
example with no mode conflict.
`referential_actions` is one block covering both `on delete` and `on
update` and all four actions (the action set is the concept; the block
explains the choice rather than each action in isolation).
### D5 — The content model: `what` / `example` / `concept`, with mode-keyed examples
Each `hint.concept.<topic>` block keeps the ADR-0053 D3 three-part shape —
`what` / `example` / `concept`. The one extension is the **example**: to
honour ADR-0053 D6's "mode-correct example, no cross-mode sharing" rule
for topics reachable in both modes (the `/runda` finding), a both-mode
topic carries **two** examples keyed by mode; a single-mode topic carries
one plain example.
```yaml
# both-mode topic — mode-keyed example
hint.concept.referential_actions:
what: "Decide what happens to child rows when the parent they point at is deleted, or its key changes."
example:
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
concept: "A foreign key forbids orphans by default (restrict). `cascade`
deletes the children with the parent; `set null` keeps them but clears
the link. The action encodes a real rule about your data."
# single-mode topic — one plain example
hint.concept.cardinality_one_to_many:
what: "Link two tables so one parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "1:n is one parent, many children. The child table holds the
foreign key column pointing back at the parent — that single shared
value is what groups a parent's children together."
```
`what`/`example` are always present; `concept` is the teaching payload
(it is what makes this tier-3). The both-mode topics are
`referential_actions`, `primary_key`, `unique`, `check`; the single-mode
(simple-only) topics are `cardinality_one_to_many`,
`cardinality_many_to_many`; `foreign_key` is single-mode (advanced-only).
The blocks live under the existing `hint:` namespace in
`src/friendly/strings/en-US.yaml`, alongside `hint.cmd.*` / `hint.err.*`,
and every leaf key is declared in
`src/friendly/keys.rs::KEYS_AND_PLACEHOLDERS` (the validator requires
exhaustive declaration — no prefix exemption for the `hint.*` families).
For both-mode topics that means `hint.concept.<topic>.example.simple` +
`.example.advanced`; for single-mode topics, `hint.concept.<topic>.example`.
**Authoring constraints** (project copy rules — see `CLAUDE.md`): concept
text is user-facing, so it must **never name the database engine** and
must **not say "DSL"** — use "simple mode" / "advanced mode". The `concept`
prose teaches the relational idea in plain language; examples are
copyable, mode-correct lines.
### D6 — Rendering: the concept block layers under the form block
This is an **additive** F1 surface. When F1 fires on live input
(`note_hint_for_input`, `src/app.rs:3200`):
1. Resolve and emit the per-form `hint.cmd.<form>` block exactly as today
(unchanged).
2. **Then** call `concept_topic_at_cursor(buffer, cursor, …)`. If it
yields a topic and `hint.concept.<topic>.what` exists, emit a second
tier-3 block beneath the form block.
The two blocks are visually distinguished by their **heading**: the form
block keeps the existing `hint.block.heading` ("Hint"); the concept block
carries a distinct sub-heading from a new `hint.block.concept_heading`
(e.g. "About this clause") so the learner sees the form-level help *and*
the clause-level concept without confusing the two.
`emit_tier3_block` gains **two** small parameters: the heading key, and an
optional mode used to resolve the example. Its example lookup becomes:
prefer `<stem>.example.<mode>` (where `<mode>` is `simple`/`advanced` from
the live `effective_mode`), else fall back to a plain `<stem>.example`.
This keeps a single renderer for all three tier-3 families — `hint.cmd.*`
and `hint.err.*` keep a plain `.example` (lookup falls straight through),
while `hint.concept.*` both-mode topics resolve the mode-keyed variant.
The mode-correct-example contract of ADR-0053 D6 is thereby honoured by
the *content keying* + this lookup, not by duplicating the renderer.
The concept block is **never shown alone** and **never replaces** the form
block — consistent with the issue ("layered on top of … does not
replace"). If the per-form resolution fails (rare; falls back to tier-2),
the concept layer is skipped too — F1 keeps a single coherent behaviour.
The submitted `hint` command (last-error route, D2 of ADR-0053) is
**unchanged** — it has no cursor, so concept hints do not apply there.
The rendered shape of a form-block-plus-concept-block is locked by a new
`insta` snapshot (`hint_block_with_concept`).
### D7 — Comprehensiveness, validation, fallback
- **Validation (build/test gate):** every `hint.concept.<topic>` leaf key
is declared in `keys.rs`; the existing `keys_validate_against_catalog`
test fails on a typo or an undeclared key, in either direction.
- **Comprehensiveness coverage test:** extend the ADR-0053
comprehensiveness test with a `CONCEPT_TOPICS` table — each entry a
`(topic, ModeCoverage)` where `ModeCoverage` is `Both` or
`Single` — asserting: (a) every topic resolves to `hint.concept.<topic>`
with a `.what` (and, per its `ModeCoverage`, either `.example.simple`
+`.example.advanced` for `Both`, or a plain `.example` for `Single`);
and (b) every `Node::Concept` wrapper reachable from the REGISTRY
carries a topic in the table. (b) needs a **recursive `Node`-tree
visitor** — `Node` is a recursive enum (`Seq(&[Node])` / `Choice` /
`Optional` / `Repeated` / `Hinted` / `Concept`), so the test walks each
REGISTRY shape collecting `Node::Concept` topics. A wrapper added
without content (or a topic with a wrapper missing) fails the test —
this is what makes the clause set enforceable rather than aspirational,
since `keys.rs` only checks that *referenced* keys resolve.
- **Graceful degrade:** `emit_tier3_block` already returns `false` when a
`.what` key is absent, so a missing concept block degrades to "form
block only" — never a blank or an error. The validation above means
that path is a safety net, not the plan.
## Forks
- **Detection precision = anywhere inside the clause** (user, 2026-06-22)
rather than slot-boundary-only. Drives the span-recording mechanism
(D1) over the simpler `pending_hint_mode` reuse.
- **Clause scope = all four families** (user, 2026-06-22) rather than the
issue's "start with referential actions + constraint slots" subset.
- **Mechanism = full-buffer span-containment, not `WalkBound::Position`**
(D3) — keeps the whole parse and leaves the dormant cursor-bound path
untouched. *Engineering choice; rationale in D3.*
- **Mode-keyed examples for both-mode topics** (user, 2026-06-22,
resolving the `/runda` finding) rather than splitting into
`_simple`/`_advanced` blocks — one concept block per topic, two examples
selected by live mode (D5/D6). Honours ADR-0053 D6's mode-correct-example
rule without duplicating concept prose.
- **Cardinality split into two blocks** (D4) rather than one — the `1:n`
and `m:n` concepts diverge (junction table). *Pedagogy choice.*
- **`primary_key` shared between `with pk` and the constraint slot** (D4)
— one concept, two syntaxes (a mode-keyed example per D5). *Content
choice.*
- **Distinct sub-heading for the concept block** (D6) rather than merging
into one block or an undifferentiated second "Hint" — keeps form-level
and clause-level guidance legible. *UX choice.*
## Consequences
- **A fourth contextual surface exists** within tier-3: clause-level
teaching, deeper than tier-2's candidate list, narrower than the
per-form block, surfaced by cursor position on F1.
- **One new grammar node** (`Node::Concept`), **one new `WalkContext`
field** (`concept_spans`), **one new resolver**
(`concept_topic_at_cursor`), **two new renderer parameters** (heading
key + example mode), and **~10 `Node::Concept` wrappers** across both
constraint grammars (exact count pinned at implementation per D4's
shared-node note), plus **two extracted cardinality sub-`Seq` nodes**.
No change to what parses; no change to tier-2; no change to the
submitted `hint` path.
- **A new content sub-namespace** — `hint.concept.*` (seven topics; the
four both-mode topics carry two examples each) — enters the catalogue
under `hint:`, validated by `keys.rs` and the comprehensiveness test.
Ongoing surface: a future clause worth teaching ships its
`Node::Concept` wrapper + block + coverage entry together (a checklist
item for feature ADRs that add clauses).
- **CHANGELOG:** a new `[Unreleased] → Added` bullet (user-facing F1
behaviour), phrased under the copy rules (no engine name; "simple
mode"/"advanced mode", not "DSL").
- **Testing:** Tier-1 unit tests for `concept_topic_at_cursor` across each
clause (cursor at boundary, cursor inside typed text, cursor outside →
`None`, nested `references`→`on delete` innermost-wins, both modes);
the F1 layering logic (form block always; concept block appended iff a
topic resolves; concept skipped when the form block falls back); the
`:` one-shot strip carried through (it must still resolve advanced
forms); Tier-2 `insta` snapshot `hint_block_with_concept`; Tier-3
integration tests (type a partial relationship command, move the cursor
into `on delete`, F1 → form block + referential-actions concept block,
**buffer / cursor / completion memo untouched**); the comprehensiveness
+ `keys.rs` validation tests (D7).
## Out of scope
- **Concept hints on the submitted `hint` command** — OOS: no cursor; the
last-error route is unchanged (D6).
- **A concept block for every clause in the grammar** — OOS for v1: the
eight wrappers cover the highest-value relational concepts (D4). More
can be added additively later, gated by the comprehensiveness test.
- **Surfacing concept text in the always-on tier-2 ambient panel** — OOS
(rejected): tier-2 stays terse by design (ADR-0022); the concept layer
is on-demand via F1, consistent with all of tier-3.
- **A `hint.concept.<topic>` reachable by an explicit argument** (e.g.
`hint cascade`) — OOS: ADR-0053 D1 keeps `hint` argument-free; `help
<topic>` owns explicit reference lookup.
- **Localisation beyond `en-US`** — OOS (deferred): the catalogue is i18n-
structured (ADR-0019) but English-only for v1 (requirements X2).
## Content inventory (implementation tracking)
Seven `hint.concept.<topic>` blocks, authored to the ADR-0053 D7 voice
(`what` / `concept` always; example(s) per mode coverage):
- [x] `referential_actions` *(both — 2 examples)* — on delete/update;
cascade / set null / restrict / no action.
- [x] `cardinality_one_to_many` *(simple — 1)* — one parent owns many
children; the FK lives on the child.
- [x] `cardinality_many_to_many` *(simple — 1)* — junction table; ADR-0045
auto-creation.
- [x] `primary_key` *(both — 2)* — uniquely identifies a row; shared by
`with pk` (simple) and the `create table` constraint (advanced).
- [x] `unique` *(both — 2)* — no two rows share the value; distinct from a
PK.
- [x] `check` *(both — 2)* — a per-row rule enforced on write.
- [x] `foreign_key` *(advanced — 1)* — a child column promising to point
at a real parent.
Implementation artifacts: the `Node::Concept` wrappers (D4 table, ~10
across both grammars + the two extracted cardinality sub-`Seq`s); the
`CONCEPT_TOPICS` coverage table (topic + `ModeCoverage`); the `keys.rs`
declarations (mode-keyed example leaves for the four both-mode topics);
the `hint_block_with_concept` snapshot; the CHANGELOG bullet.