From 2c61553245fc9afcb6f40b8d9e73eb7e9151b0a6 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 16 May 2026 14:17:40 +0000 Subject: [PATCH 1/3] =?UTF-8?q?propose:=20hints-v3=20=E2=80=94=20kind/dire?= =?UTF-8?q?ction-aware=20empty-result=20hints=20+=20proposes-order=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds propose/HINTS-V3-PROPOSE.md (draft) and docs/PROPOSES-ORDER.md. HINTS-V3 replaces the generic TPL_NEIGHBORS_EMPTY_KIND_CHECK with a family of four templates (wrong subject kind, wrong direction, type-level-requery, brownfield-resolver-absence) driven by EDGE_SCHEMA from SCHEMA-V2. Implementation = SCHEMA-V2 PR-D. This propose unblocks PR-D per SCHEMA-V2 Decision 30 by existing as a draft PR. docs/PROPOSES-ORDER.md records the lock + merge order for the in-flight SCHEMA-V2 + HINTS-V3 set (4 code PRs, A→B→C→D, sequential). --- docs/PROPOSES-ORDER.md | 80 +++++++++++ propose/HINTS-V3-PROPOSE.md | 256 ++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 docs/PROPOSES-ORDER.md create mode 100644 propose/HINTS-V3-PROPOSE.md diff --git a/docs/PROPOSES-ORDER.md b/docs/PROPOSES-ORDER.md new file mode 100644 index 0000000..eab77d9 --- /dev/null +++ b/docs/PROPOSES-ORDER.md @@ -0,0 +1,80 @@ +# In-flight proposes: lock order and merge sequence + +**Status**: living document +**Last updated**: 2026-05-16 + +This document records the dependency order for proposes and code PRs that are currently in flight against `master`. It supplements (does not replace) each propose's `§6 — Migration plan` section. + +## Why this exists + +When two or more proposes touch overlapping subsystems, the order they lock and the order their code PRs merge matters. Encoding that order in one place — instead of scattering it across propose decisions — prevents drift between "what propose A claims about propose B" and "what propose B actually says." + +## Current in-flight set (as of 2026-05-16) + +1. **SCHEMA-V2** — `propose/SCHEMA-V2-PROPOSE.md` (locked via #151) + - 4 code PRs: PR-A (`EDGE_SCHEMA` + ontology v14 bump), PR-B (`HTTP_CALLS` flip + downstream API), PR-C (`Producer` node + `ASYNC_CALLS` flip + GraphMeta + MCP parity), PR-D (hints v3). +2. **HINTS-V3** — `propose/HINTS-V3-PROPOSE.md` (this propose, draft) + - 1 code PR, which is the same PR-D enumerated in SCHEMA-V2. + +No other proposes are in flight. + +## Lock and merge order + +### Phase 1 — propose locks + +``` +SCHEMA-V2-PROPOSE.md [LOCKED via #151] + ↓ (decision 30: PR-D blocked until HINTS-V3 propose exists as draft PR) +HINTS-V3-PROPOSE.md [draft PR] +``` + +SCHEMA-V2 propose is already merged. HINTS-V3 propose opens as a draft PR (this propose). Both must reach `Status: locked` before any code PR-A merges. SCHEMA-V2 Decision 30 makes the gating explicit. + +### Phase 2 — plan + cursor-prompt artefacts + +``` +plans/PLAN-SCHEMA-V2.md +plans/CURSOR-PROMPTS-SCHEMA-V2.md +plans/PLAN-HINTS-V3.md +plans/CURSOR-PROMPTS-HINTS-V3.md +``` + +SCHEMA-V2 Decision 29 makes `PLAN-SCHEMA-V2.md` + `CURSOR-PROMPTS-SCHEMA-V2.md` a merge gate for PR-A. By analogy, `PLAN-HINTS-V3.md` + `CURSOR-PROMPTS-HINTS-V3.md` are a merge gate for PR-D. + +Plans + prompts can be drafted in parallel; they don't have to be merged before each other. They do all have to be merged before their respective code PRs. + +### Phase 3 — code PRs (merge order) + +``` +PR-A feat(schema): add EDGE_SCHEMA + generate docs/EDGE-NAVIGATION.md + bump ontology to v14 + ↓ +PR-B feat(schema): HTTP_CALLS originates from Client, not Symbol (+ downstream API + HTTP docs) + ↓ +PR-C feat(schema): introduce Producer node and route ASYNC_CALLS through it + ↓ (HINTS-V3 propose must be LOCKED, not just draft, by this point) +PR-D feat(hints): kind- and direction-aware empty-result hints driven by EDGE_SCHEMA +``` + +PR-A is strictly first because every subsequent PR depends on `EDGE_SCHEMA` + the ontology version bump. PR-B and PR-C are sequential because PR-C's MCP-parity / GraphMeta / type-level-rollup additions are easier to review on top of a clean PR-B. PR-D is last because its templates substitute post-flip `src_kind` / `dst_kind` from the final `EDGE_SCHEMA`. + +No PR in this set is parallelizable. Each depends on its predecessor's `master` shape. + +## Re-index moments + +`ONTOLOGY_VERSION` 13 → 14 lands in PR-A. **One** re-index is required across the whole sequence; PRs B/C/D do not bump the version again. The README + `docs/AGENT-GUIDE.md` "Re-index required" sections are updated in PR-A; PR-B/C/D may amend wording but do not re-trigger. + +## What this document does NOT cover + +- Per-PR file-path / function-signature detail — that's the per-PR plan (`plans/PLAN-*.md`). +- Cursor task prompts — `plans/CURSOR-PROMPTS-*.md`. +- Out-of-scope proposes (TIER2-INCREMENTAL-REBUILD, RANKING-MICROSERVICE, ENHANCED-ROLE-RECOGNITION, INDEX-AUTO-MODE) — those are not in the merge sequence right now. When one becomes in-flight, this doc is updated. +- Review cycles inside a single propose or PR — see each PR's review threads. + +## Maintenance + +This file is updated whenever: +- A new propose enters draft. +- A propose locks or its code PRs merge. +- The dependency graph changes. + +Stale rows are deleted when their code PRs land in `master`. After PR-D merges, this whole document collapses to "no proposes in flight" until the next one starts. diff --git a/propose/HINTS-V3-PROPOSE.md b/propose/HINTS-V3-PROPOSE.md new file mode 100644 index 0000000..ab05119 --- /dev/null +++ b/propose/HINTS-V3-PROPOSE.md @@ -0,0 +1,256 @@ +# HINTS-V3 — kind- and direction-aware empty-result hints driven by EDGE_SCHEMA + +**Status**: draft +**Author**: Dmitriy Teriaev + Perplexity Computer +**Date**: 2026-05-16 + +## TL;DR + +- Replace the single generic empty-neighbors template `TPL_NEIGHBORS_EMPTY_KIND_CHECK = "0 results — check if the requested edge_types apply to this kind"` with a small family of kind- and direction-aware templates driven by `EDGE_SCHEMA` (introduced in `propose/SCHEMA-V2-PROPOSE.md` §3.4). +- Each template fires by inspecting the subject node kind, the requested `direction`, and the requested `edge_types` against `EDGE_SCHEMA[edge].src` / `.dst` / `.typical_traversals` — no hardcoded edge-shape literals in `mcp_hints.py`. +- New emit-side input: hints v3 also reads `BROWNFIELD_RESOLVER_STRATEGY_SET` (added in SCHEMA-V2 PR-A) to fire a distinct *"absence may mean unresolved, not absent"* hint that complements (does not replace) the v2 `TPL_NEIGHBORS_FUZZY_STRATEGY` hint. +- Migration: 1 PR (= SCHEMA-V2 PR-D), gated on this propose locking and on SCHEMA-V2 PR-A through PR-C all merging first. Re-index is already required by SCHEMA-V2 (`ONTOLOGY_VERSION` 13 → 14); HINTS-V3 does not bump it again. +- Goes away: `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (deleted). Stays: every existing v1/v2 template (DESCRIBE rollups, FIND, RESOLVE, fuzzy-strategy hint). +- Non-obvious constraint: hints v3 must never recommend a dot-key edge label as a `neighbors()` argument (carry-over from v2 propose §7.x). All template recommendations are checked against the canonical edge list. + +## §1 — Frame + +> Hints v3 is a thin translator from `EDGE_SCHEMA` to natural-language nudges. It owns no edge knowledge of its own. + +The v1 empty-neighbors template is a placeholder: it tells the agent "your kind might be wrong" but doesn't tell it which kind, which direction, or what to call instead. SCHEMA-V2 makes that information mechanically derivable — `EDGE_SCHEMA[e].src`, `.dst`, `.typical_traversals` answer "what kinds attach to this edge?" and "what's the canonical traversal from a wrong-kind subject?". HINTS-V3 is what consumes that data at empty-result time. + +The frame rules out three temptations: + +- Hand-written per-edge templates ("for HTTP_CALLS, suggest DECLARES_CLIENT") — that's the bug v1 has. Knowledge lives in `EDGE_SCHEMA`, hints render it. +- Reasoning about why the result is empty (graph state, indexing, ranking) — out of scope. Hints v3 only handles structurally-impossible queries (wrong kind, wrong direction, wrong edge for kind) and brownfield-resolver absence. +- Cross-edge composition planning (multi-hop suggestions beyond the single canonical traversal stored in `EDGE_SCHEMA`) — out of scope. One canonical traversal per (subject_kind, edge, direction) tuple, sourced from `typical_traversals`. + +## §2 — Design principles + +1. **`EDGE_SCHEMA` is the only source of edge-shape knowledge.** No edge name, src/dst kind, or traversal string appears as a literal in `mcp_hints.py` outside of test fixtures. +2. **One template per dimension of mismatch.** Subject-kind mismatch, direction mismatch, type-vs-method-level mismatch, and brownfield-resolver absence are four distinct templates, not one polymorphic one. +3. **Hints v3 never recommends a dot-key edge label.** Generator output is filtered against the canonical edge list before emission. +4. **Hints v3 reads `BROWNFIELD_RESOLVER_STRATEGY_SET` and `FUZZY_STRATEGY_SET` independently.** Membership in the fuzzy set fires the v2 fuzzy hint (unchanged); membership in the broader resolver set fires the new "may be unresolved" hint. The two can both fire when the edge is fuzzy-resolved by the brownfield resolver — they are not mutually exclusive. +5. **Templates carry the canonical traversal verbatim from `EDGE_SCHEMA[e].typical_traversals`.** No re-rendering or string editing of the traversal inside `mcp_hints.py`. +6. **Empty-result hints are advisory, capped at 5 total per output (v1 invariant), and priority-ordered.** Kind/direction hints sit at `PRIORITY_LEAF_FOLLOWUP=2` like the v1 template they replace. +7. **A subject node kind that already matches the edge schema does not get a kind-mismatch hint, even on empty result.** Hints v3 only fires when there is a *structural* reason to expect the query to fail — never on "the graph happens to have no rows." + +## §3 — The proposed surface + +### §3.1 — Templates added + +```python +# Replaces TPL_NEIGHBORS_EMPTY_KIND_CHECK. + +TPL_NEIGHBORS_WRONG_SUBJECT_KIND = ( + "0 results — '{edge}' connects {src_kind} → {dst_kind}; " + "this is a {subject_kind}. Try: {canonical_traversal}" +) + +TPL_NEIGHBORS_WRONG_DIRECTION = ( + "0 results — '{edge}' is {src_kind} → {dst_kind}; " + "you requested direction='{requested_dir}'. Try direction='{correct_dir}'." +) + +TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = ( + "0 results — '{edge}' lives on methods, not on {subject_kind}. " + "Try: neighbors(['{id}'],'out',['DECLARES']) then " + "neighbors(member_ids,'{direction}',['{edge}'])" +) + +TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = ( + "edges on '{edge}' are emitted by the brownfield resolver — " + "absence here may mean unresolved (no matching annotation/target), " + "not absent from the codebase" +) +``` + +The fuzzy-strategy template `TPL_NEIGHBORS_FUZZY_STRATEGY` from v2 stays unchanged. `TPL_NEIGHBORS_EMPTY_KIND_CHECK` is deleted. + +### §3.2 — Generator entry point + +Hints v3 adds one generator function consumed by `neighbors`: + +```python +def neighbors_empty_hints( + *, + subject_record: dict[str, Any], # the node looked up by id + requested_edge_types: list[str], + requested_direction: Literal["in", "out", "any"], +) -> list[tuple[int, str]]: + """Emit at most one structural mismatch hint per requested edge, plus a + brownfield-resolver hint if any requested edge is brownfield-resolved. + Returns scored hints; caller merges with other sources and applies finalize_hint_list. + """ +``` + +The function: + +1. Reads `EDGE_SCHEMA[edge]` for each requested edge type. +2. Compares `subject_record`'s node label + `data.kind` against `EdgeSpec.src` (for `direction="out"`) or `.dst` (for `direction="in"`); `direction="any"` checks both. +3. Picks the first applicable mismatch template per edge (subject-kind > direction > type-level), substitutes from the spec, and assigns priority `PRIORITY_LEAF_FOLLOWUP=2`. +4. If any requested edge has `EdgeSpec.brownfield_resolver_sourced=True`, emits `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` once (deduped if multiple such edges) at priority `PRIORITY_LEAF_FOLLOWUP=2`. + +The function does **not** emit the v2 fuzzy-strategy hint — that path stays in the existing non-empty-result hint generator (since `FUZZY_STRATEGY_SET` membership is per-row, not per-edge-schema). + +### §3.3 — Hint emission rules summary + +| Trigger | Template | Priority | +|---|---|---| +| Subject's node kind is not in `EdgeSpec.src` (for `direction="out"`) or `.dst` (for `direction="in"`) | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` | 2 | +| Subject's node kind matches the opposite endpoint of the requested direction | `TPL_NEIGHBORS_WRONG_DIRECTION` | 2 | +| Subject is a Symbol with `symbol_kind ∈ _TYPE_SYMBOL_KINDS` and the requested edge lives on methods (i.e., `src` or `dst` is `Symbol` but `EdgeSpec.member_only=True` — see §3.4) | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` | 2 | +| Any requested edge has `EdgeSpec.brownfield_resolver_sourced=True` | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` | 2 | + +At most one of the first three fires per edge (in the order shown). The brownfield hint can co-fire with any of them. + +### §3.4 — Required `EdgeSpec` field additions (preview) + +For hints v3 to do its job without literal edge-shape knowledge, `EdgeSpec` (defined in SCHEMA-V2 §3.4 / Appendix A) must carry one bit not strictly required by SCHEMA-V2 alone: + +- `member_only: bool` — True iff the edge attaches only to `Symbol` rows whose `symbol_kind ∈ {method, constructor}` (e.g., `DECLARES_CLIENT`, `DECLARES_PRODUCER`, `EXPOSES`, `HTTP_CALLS` post-flip via Client which is declared by methods, `ASYNC_CALLS` post-flip via Producer). + +`member_only` is a hint-engine-only field. The DDL-consistency CI check does not assert it (kuzu has no notion). It is set in `EDGE_SCHEMA` and read by `neighbors_empty_hints`. **This is the only `EdgeSpec` field added solely for hints v3.** + +If SCHEMA-V2's `EdgeSpec` does not yet include `member_only`, this propose's PR-D adds it. + +### §3.5 — What does NOT change + +- `TPL_DESCRIBE_*` family (v1, type/method/route/client describe rollups): unchanged. +- `TPL_FIND_*`, `TPL_RESOLVE_*`, `TPL_SEARCH_WEAK`: unchanged. +- `TPL_NEIGHBORS_FUZZY_STRATEGY` (v2 fuzzy hint): unchanged. +- Priority constants, `finalize_hint_list`, the `MCP_HINTS_FIELD_DESCRIPTION` schema description: unchanged. +- The 5-hint output cap: unchanged. +- No new MCP tool, no new tool argument. `neighbors` already accepts the inputs hints v3 needs. + +## §4 — Use-case re-walk + +Each row references the SCHEMA-V2 use-case re-walk (§4 of that propose) where applicable. New rows are HV-prefixed. + +| # | Use case | Subject | Request | Pre-v3 hint | Post-v3 hint | +|---|---|---|---|---|---| +| HV1 (= SCHEMA-V2 UC2) | Class-level subject, asks `DECLARES_CLIENT` outbound | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['DECLARES_CLIENT'])` | `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (generic) | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` — `neighbors(['{id}'],'out',['DECLARES'])` then `neighbors(member_ids,'out',['DECLARES_CLIENT'])` | +| HV2 (= SCHEMA-V2 UC3) | Method-level subject, asks `HTTP_CALLS` outbound (post-flip) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['HTTP_CALLS'])` | `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (generic) | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — `'HTTP_CALLS' connects Client → Route; this is a Symbol. Try: neighbors(['{id}'],'out',['DECLARES_CLIENT']) then neighbors(client_ids,'out',['HTTP_CALLS'])` | +| HV3 | Method-level subject, asks `ASYNC_CALLS` outbound (post-flip) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['ASYNC_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` pointing at `DECLARES_PRODUCER` then `ASYNC_CALLS` | +| HV4 (= SCHEMA-V2 UC15) | Producer subject, no resolved topic, asks for downstream | `Producer{}` | `neighbors([producer_id], 'out', ['ASYNC_CALLS'])` returning `[]` | n/a (Producer didn't exist) | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` — "ASYNC_CALLS edges are brownfield-resolver-emitted; absence may mean unresolved" | +| HV5 (= SCHEMA-V2 UC17) | Producer subject, asks `ASYNC_CALLS` inbound | `Producer{}` | `neighbors([producer_id], 'in', ['ASYNC_CALLS'])` | n/a | `TPL_NEIGHBORS_WRONG_DIRECTION` — `'ASYNC_CALLS' is Producer → Route; you requested direction='in'. Try direction='out'.` | +| HV6 | Client subject, asks `HTTP_CALLS` inbound | `Client{}` | `neighbors([client_id], 'in', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_DIRECTION` — same shape as HV5 | +| HV7 | Route subject, asks `HTTP_CALLS` outbound | `Route{}` | `neighbors([route_id], 'out', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_DIRECTION` | +| HV8 | Symbol method, asks `EXPOSES` outbound — empty because not a controller method | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['EXPOSES'])` returning `[]` | generic | **No hint** — subject kind matches `EXPOSES.src=Symbol`, direction is correct, edge is method-level and subject is a method. The empty result is *graph state*, not structural. (Principle 7.) | +| HV9 | Symbol method, asks `DECLARES_CLIENT` outbound — empty because the method has no `@CodebaseHttpClient` | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['DECLARES_CLIENT'])` returning `[]` | generic | **No hint** — kind+direction+member-level all correct. Graph state, not structural. | +| HV10 (= SCHEMA-V2 UC22) | Class-level subject, asks `HTTP_CALLS` outbound (post-flip) | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — `'HTTP_CALLS' connects Client → Route; this is a Symbol(class).` Plus, because the subject is a *type*-kind Symbol, the canonical traversal from `EDGE_SCHEMA["HTTP_CALLS"].typical_traversals[type_subject]` is substituted. | +| HV11 | Method subject, asks `OVERRIDES` outbound, no superclass method to override | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['OVERRIDES'])` returning `[]` | generic | **No hint** — structural query is fine. | +| HV12 | Annotation symbol, asks `EXTENDS` outbound | `Symbol{symbol_kind=annotation}` | `neighbors([ann_id], 'out', ['EXTENDS'])` returning `[]` | generic | If `EXTENDS.src ∋ Symbol` but `member_only=False` and kind is fine: no hint. If schema records that annotations cannot extend, `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` fires. (Depends on `EXTENDS` `EdgeSpec` finalization; documented in SCHEMA-V2 PR-A.) | +| HV13 | Client subject, asks `HTTP_CALLS` outbound — empty because the Client's `target_path` did not match any route (brownfield resolver failed) | `Client{}` | `neighbors([client_id], 'out', ['HTTP_CALLS'])` returning `[]` | generic | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED`. Subject kind + direction + member-level are all correct, but `EdgeSpec.brownfield_resolver_sourced=True`. | +| HV14 | Producer subject, asks `ASYNC_CALLS` outbound — empty because the Producer's broker is unknown | `Producer{}` | `neighbors([producer_id], 'out', ['ASYNC_CALLS'])` returning `[]` | n/a | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` | +| HV15 | Method subject, asks both `HTTP_CALLS` and `DECLARES_CLIENT` outbound | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['HTTP_CALLS', 'DECLARES_CLIENT'])` returning HTTP `[]`, DECLARES_CLIENT `[]` | one generic hint | One `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` hint for `HTTP_CALLS`; **no hint** for `DECLARES_CLIENT` (HV9 logic). Two requested edges, one structural mismatch, one structurally-fine empty. | +| HV16 | Method subject, asks `ASYNC_CALLS` outbound; mixed brownfield-strategy edges actually present (non-empty result) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['ASYNC_CALLS'])` returning N edges | v2 fuzzy hint fires if any edge has fuzzy strategy | Same as v2 — `neighbors_empty_hints` is not invoked on non-empty results. v2 fuzzy hint logic unchanged. | +| HV17 | Class-level subject, asks `EXPOSES` outbound | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['EXPOSES'])` | generic | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` — class is wrong member level for `EXPOSES`, suggest `DECLARES` then re-query. | +| HV18 | Mixed-direction `direction='any'` request on a non-fitting kind | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'any', ['HTTP_CALLS'])` returning `[]` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — neither `HTTP_CALLS.src` nor `.dst` matches `Symbol`; the canonical traversal from `typical_traversals` is suggested. | +| HV19 | `EDGE_SCHEMA` reader cross-check from a CI test | n/a | n/a | n/a | Test asserts every edge in `EDGE_SCHEMA` is reachable via at least one of the four templates above; no edge falls through to "no applicable hint generator". | +| HV20 | Future edge added (e.g. `INHERITS_FROM_FRAMEWORK`) | varies | varies | requires hand-edit | Adds entry to `EDGE_SCHEMA`; hints v3 picks it up automatically with no code change. | + +### Awkward cases surfaced + +- **HV12** depends on the final shape of `EdgeSpec` for `EXTENDS` — whether annotations are excluded from `src`. The propose doesn't lock that; SCHEMA-V2 PR-A is the right place. HINTS-V3 just consumes whatever PR-A produces. +- **HV15** demonstrates that the per-edge fan-out is not a problem in practice because the 5-hint cap and the dedup pass collapse redundant brownfield-resolver hints. +- **HV13/HV14** are the case the v2 fuzzy hint doesn't cover: the brownfield resolver tried, found nothing, emitted no edge — there is no `attrs.strategy` to inspect because there is no edge. The dedicated v3 brownfield template handles this asymmetry. + +## §5 — What this deliberately does NOT do + +| Question / feature | Why we skip it | +|---|---| +| Generate per-edge bespoke prose (e.g. "Did you mean DECLARES_CLIENT? Here's why HTTP works this way…") | Out of scope. Hints are road signs, not tutorials. `MCP_HINTS_FIELD_DESCRIPTION` describes the contract. | +| Cross-edge composition planner (multi-hop suggestions beyond the canonical traversal in `typical_traversals`) | Out of scope. One canonical traversal per (edge, subject-kind, direction) lives in `EDGE_SCHEMA`. Composition belongs to the agent. | +| Reason about graph state (ranking, indexing, freshness) at empty-result time | Out of scope. Hints v3 only handles structurally-impossible queries and brownfield-resolver absence. | +| Add new templates that recommend dot-key edge labels (e.g. `DECLARES.DECLARES_CLIENT`) as `neighbors()` arguments | Forbidden by v2 invariant (`MCP_HINTS_FIELD_DESCRIPTION` last sentence). Generator output is checked. | +| Cache hint outputs across queries | No state. Hints are pure functions of subject + request + `EDGE_SCHEMA`. | +| Add a hint emission for `find` / `resolve` / `describe` empty results that's edge-schema-aware | Out of scope. Those tools already have dedicated hint families (`TPL_FIND_EMPTY_RESOLVE`, `TPL_RESOLVE_NONE_*`, `TPL_DESCRIBE_*`). | +| Localized hint text | Out of scope. English only, consistent with the rest of `mcp_hints.py`. | + +## §6 — Migration plan — 1 PR (= SCHEMA-V2 PR-D) + +**Merge gate**: this propose must be **locked** before SCHEMA-V2 PR-A merges (Decision 30 in SCHEMA-V2-PROPOSE.md). The implementation PR (PR-D) must merge **after** SCHEMA-V2 PR-A, PR-B, and PR-C are all in master, because the templates substitute `src_kind`/`dst_kind` from the post-flip `EDGE_SCHEMA`. + +### PR-D — kind/direction-aware empty-result hints + +**Title**: `feat(hints): kind- and direction-aware empty-result hints driven by EDGE_SCHEMA` + +**Purpose**: + +- Delete `TPL_NEIGHBORS_EMPTY_KIND_CHECK`. +- Add the four templates listed in §3.1. +- Add `EdgeSpec.member_only: bool` to `EDGE_SCHEMA` entries (or fold into PR-A — see §7 Decision 6). +- Add `neighbors_empty_hints(...)` generator in `mcp_hints.py`. +- Wire it into the `neighbors` empty-result branch in `mcp_v2.py`. +- Add a dot-key edge label filter (assertion or post-filter) to enforce the v2 invariant. + +**Test summary**: named scenarios in `tests/test_mcp_hints.py` covering HV1, HV2, HV3, HV4, HV5, HV6, HV7, HV8 (no-hint case), HV10, HV13, HV15, HV17, HV18, HV19; v2-regression scenarios asserting `TPL_NEIGHBORS_FUZZY_STRATEGY` still fires on non-empty fuzzy-resolved results; `MCP_HINTS_FIELD_DESCRIPTION` invariant test that no emitted hint string contains a dot in an edge-label position. + +## §7 — Decisions taken (no longer open) + +1. **`TPL_NEIGHBORS_EMPTY_KIND_CHECK` is deleted.** It is replaced by the family in §3.1, not extended. +2. **The four mismatch dimensions are subject-kind, direction, type-vs-method-level, and brownfield-resolver absence.** One template each. +3. **At most one of the first three templates fires per requested edge.** Order: subject-kind > direction > type-level. +4. **`TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` can co-fire with any of the first three, but is deduped across all requested edges.** +5. **Priority of all four new templates is `PRIORITY_LEAF_FOLLOWUP=2`** — same as the v1 template they replace. No new priority constant. +6. **`EdgeSpec.member_only: bool` is added to `EDGE_SCHEMA`.** Strictly hint-engine-only field. CI DDL-consistency check ignores it. It is preferable to land this field in SCHEMA-V2 PR-A (alongside other `EdgeSpec` fields) rather than in PR-D, but PR-D will add it if PR-A does not. +7. **Canonical traversal strings come verbatim from `EDGE_SCHEMA[e].typical_traversals`.** Hints v3 does not synthesize traversal strings. +8. **`typical_traversals` may be keyed by subject role** (e.g. `type_subject`, `member_subject`) where the canonical traversal differs by subject kind. Exact key set is finalized by SCHEMA-V2 PR-A. +9. **Hints v3 reads `BROWNFIELD_RESOLVER_STRATEGY_SET` via `EdgeSpec.brownfield_resolver_sourced`,** not by enumerating the set itself. The set is only used inside the per-row `_any_fuzzy_strategy`-style helper for the v2 fuzzy hint. +10. **No `member_only=True` edge between two non-Symbol nodes is permitted in `EDGE_SCHEMA`.** This invariant is asserted by a unit test in PR-A or PR-D. +11. **The 5-hint output cap (v1 invariant) is unchanged.** `finalize_hint_list` is reused. +12. **HINTS-V3 ships as SCHEMA-V2 PR-D, not as a separate code PR sequence.** The propose lives in its own PR but the implementation is one of the four schema-v2 code PRs. +13. **`mcp_hints.py` imports `EDGE_SCHEMA` from `java_ontology`.** No copy. No selective re-export. +14. **No back-compat alias for `TPL_NEIGHBORS_EMPTY_KIND_CHECK`.** Per locked repo rule "Breaking changes allowed; no active users." + +## §8 — Risks and how we mitigate + +| Risk | Mitigation | +|---|---| +| `EDGE_SCHEMA` doesn't carry enough information for a useful canonical traversal on every (edge, subject-kind) pair | `typical_traversals` is keyed by subject role. Test HV19 asserts every edge in `EDGE_SCHEMA` produces at least one applicable template + traversal for at least one realistic subject-kind. | +| A template fires spuriously on a structurally-correct empty (HV8/HV9/HV11 false positives) | Principle 7 + named scenarios HV8, HV9, HV11 in PR-D test suite explicitly cover this. The mismatch templates require *structural* impossibility — kind, direction, or member-level wrong — never just "empty rows". | +| Dot-key edge labels leak into a hint recommendation despite the v2 invariant | Post-filter on rendered hints checks for `\.` in any single-quoted edge_types list and raises in tests. | +| `EdgeSpec.member_only` becomes ambiguous for edges like `EXTENDS` that can connect any Symbol kind | `member_only=False` is the default. We only set `True` on edges whose `src` and `dst` are unambiguously method-level (`DECLARES_CLIENT`, `DECLARES_PRODUCER`, `EXPOSES`, `OVERRIDES`, `CALLS`). | +| Hints v3 lands before SCHEMA-V2 PR-A/B/C, references nonexistent `Client`/`Producer` shapes | Gating: PR-D merge-blocks behind PR-C (declared in this propose §6 and in SCHEMA-V2 §6). | +| `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` duplicates `TPL_NEIGHBORS_FUZZY_STRATEGY` on edges that are both brownfield-resolved and fuzzy-strategy | They cover different axes: one fires on empty results from a brownfield-resolved edge; the other on non-empty results that contain fuzzy-strategy rows. Empty + non-empty are exclusive branches of `neighbors` post-processing; both hints cannot fire on the same call. Test HV4 + HV16 cover the two branches. | +| Future edge additions break HINTS-V3 silently | Test HV19 (every edge in `EDGE_SCHEMA` has at least one template path) is a CI invariant. Adding an edge to `EDGE_SCHEMA` without a covering template causes the test to fail. | + +## Appendix A — Concrete template strings (Python) + +```python +# In mcp_hints.py, replacing the section currently containing TPL_NEIGHBORS_EMPTY_KIND_CHECK. + +TPL_NEIGHBORS_WRONG_SUBJECT_KIND = ( + "0 results — '{edge}' connects {src_kind} → {dst_kind}; " + "this is a {subject_kind}. Try: {canonical_traversal}" +) + +TPL_NEIGHBORS_WRONG_DIRECTION = ( + "0 results — '{edge}' is {src_kind} → {dst_kind}; " + "you requested direction='{requested_dir}'. Try direction='{correct_dir}'." +) + +TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = ( + "0 results — '{edge}' lives on methods, not on {subject_kind}. " + "Try: neighbors(['{id}'],'out',['DECLARES']) then " + "neighbors(member_ids,'{direction}',['{edge}'])" +) + +TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = ( + "edges on '{edge}' are emitted by the brownfield resolver — " + "absence here may mean unresolved (no matching annotation/target), " + "not absent from the codebase" +) +``` + +## Appendix B — Traceability + +This is a first-draft propose; no revisions yet. If a reviewer changes the design, this section will list **what stayed unchanged** and **what changed and why**. + +**Cross-propose references**: +- Consumes `EDGE_SCHEMA` from `propose/SCHEMA-V2-PROPOSE.md` §3.4 (locked). +- Consumes `BROWNFIELD_RESOLVER_STRATEGY_SET` from `propose/SCHEMA-V2-PROPOSE.md` §3.11 / Decision 28 (locked). +- Implements `propose/SCHEMA-V2-PROPOSE.md` §3.12 (preview) and PR-D §6 (gating). +- Builds on `propose/completed/HINTS-V2-PROPOSE.md` §7.x (5-hint cap, dot-key edge-label invariant, fuzzy-strategy hint) — unchanged. +- Builds on `propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md` Appendix A (v1 catalogue) — unchanged except `TPL_NEIGHBORS_EMPTY_KIND_CHECK` is deleted. From ac65097aee18df19ac9119f7675c6dc9d1646724 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 17:29:28 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix=20hints-v3=20propose=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20gates,=20algorithm,=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align merge gates with SCHEMA-V2 Decision 30 (draft before PR-A, lock before PR-D). Fix structural hint evaluation order so wrong-direction cases (HV5/HV6) are reachable. Use PRIORITY_META, role-keyed typical_traversals, and §3.6 neighbors payload contract. Co-authored-by: Cursor --- docs/PROPOSES-ORDER.md | 59 ++++---- propose/HINTS-V3-PROPOSE.md | 274 ++++++++++++++++++++---------------- 2 files changed, 179 insertions(+), 154 deletions(-) diff --git a/docs/PROPOSES-ORDER.md b/docs/PROPOSES-ORDER.md index eab77d9..f1751ff 100644 --- a/docs/PROPOSES-ORDER.md +++ b/docs/PROPOSES-ORDER.md @@ -11,70 +11,69 @@ When two or more proposes touch overlapping subsystems, the order they lock and ## Current in-flight set (as of 2026-05-16) -1. **SCHEMA-V2** — `propose/SCHEMA-V2-PROPOSE.md` (locked via #151) +1. **SCHEMA-V2** — `propose/SCHEMA-V2-PROPOSE.md` (propose merged via [#151](https://github.com/HumanBean17/java-codebase-rag/pull/151); treat as locked for sequencing) - 4 code PRs: PR-A (`EDGE_SCHEMA` + ontology v14 bump), PR-B (`HTTP_CALLS` flip + downstream API), PR-C (`Producer` node + `ASYNC_CALLS` flip + GraphMeta + MCP parity), PR-D (hints v3). -2. **HINTS-V3** — `propose/HINTS-V3-PROPOSE.md` (this propose, draft) - - 1 code PR, which is the same PR-D enumerated in SCHEMA-V2. +2. **HINTS-V3** — `propose/HINTS-V3-PROPOSE.md` ([#154](https://github.com/HumanBean17/java-codebase-rag/pull/154), draft) + - Implementation = SCHEMA-V2 PR-D (same PR). No other proposes are in flight. ## Lock and merge order -### Phase 1 — propose locks +### Phase 1 — propose artefacts ``` -SCHEMA-V2-PROPOSE.md [LOCKED via #151] - ↓ (decision 30: PR-D blocked until HINTS-V3 propose exists as draft PR) -HINTS-V3-PROPOSE.md [draft PR] +SCHEMA-V2-PROPOSE.md [merged #151 — locked for code sequence] + ↓ +HINTS-V3-PROPOSE.md [draft PR #154 — SCHEMA-V2 Decision 30] ``` -SCHEMA-V2 propose is already merged. HINTS-V3 propose opens as a draft PR (this propose). Both must reach `Status: locked` before any code PR-A merges. SCHEMA-V2 Decision 30 makes the gating explicit. +**Decision 30 (SCHEMA-V2)**: `HINTS-V3-PROPOSE.md` must exist as a **merged draft propose** before SCHEMA-V2 **PR-A** implementation starts. That unblocks the four-code-PR sequence; it does **not** require HINTS-V3 to be `Status: locked` before PR-A. + +**HINTS-V3 lock**: `Status: locked` is required before SCHEMA-V2 **PR-D** merges (see Phase 3). ### Phase 2 — plan + cursor-prompt artefacts ``` -plans/PLAN-SCHEMA-V2.md +plans/PLAN-SCHEMA-V2.md [not started] plans/CURSOR-PROMPTS-SCHEMA-V2.md plans/PLAN-HINTS-V3.md plans/CURSOR-PROMPTS-HINTS-V3.md ``` -SCHEMA-V2 Decision 29 makes `PLAN-SCHEMA-V2.md` + `CURSOR-PROMPTS-SCHEMA-V2.md` a merge gate for PR-A. By analogy, `PLAN-HINTS-V3.md` + `CURSOR-PROMPTS-HINTS-V3.md` are a merge gate for PR-D. +SCHEMA-V2 Decision 29: `PLAN-SCHEMA-V2.md` + `CURSOR-PROMPTS-SCHEMA-V2.md` are merge gates for **PR-A**. + +By analogy: `PLAN-HINTS-V3.md` + `CURSOR-PROMPTS-HINTS-V3.md` are merge gates for **PR-D**. -Plans + prompts can be drafted in parallel; they don't have to be merged before each other. They do all have to be merged before their respective code PRs. +Plans and prompts may be drafted in parallel with each other; each pair must land before its code PR. ### Phase 3 — code PRs (merge order) ``` -PR-A feat(schema): add EDGE_SCHEMA + generate docs/EDGE-NAVIGATION.md + bump ontology to v14 +PR-A feat(schema): EDGE_SCHEMA + docs/EDGE-NAVIGATION.md + ontology v14 + ↓ (requires HINTS-V3 propose merged as draft per Decision 30) +PR-B feat(schema): HTTP_CALLS Client → Route (+ downstream API) ↓ -PR-B feat(schema): HTTP_CALLS originates from Client, not Symbol (+ downstream API + HTTP docs) - ↓ -PR-C feat(schema): introduce Producer node and route ASYNC_CALLS through it - ↓ (HINTS-V3 propose must be LOCKED, not just draft, by this point) -PR-D feat(hints): kind- and direction-aware empty-result hints driven by EDGE_SCHEMA +PR-C feat(schema): Producer node + ASYNC_CALLS flip (+ GraphMeta / MCP parity) + ↓ (requires HINTS-V3 propose Status: locked) +PR-D feat(hints): kind/direction-aware empty-result hints (EDGE_SCHEMA-driven) ``` -PR-A is strictly first because every subsequent PR depends on `EDGE_SCHEMA` + the ontology version bump. PR-B and PR-C are sequential because PR-C's MCP-parity / GraphMeta / type-level-rollup additions are easier to review on top of a clean PR-B. PR-D is last because its templates substitute post-flip `src_kind` / `dst_kind` from the final `EDGE_SCHEMA`. +PR-A needs `EDGE_SCHEMA` infrastructure. PR-B and PR-C are sequential for review surface. PR-D consumes post-flip `src`/`dst` and must not merge until HINTS-V3 is **locked**. -No PR in this set is parallelizable. Each depends on its predecessor's `master` shape. +No PR in this set is parallelizable. ## Re-index moments -`ONTOLOGY_VERSION` 13 → 14 lands in PR-A. **One** re-index is required across the whole sequence; PRs B/C/D do not bump the version again. The README + `docs/AGENT-GUIDE.md` "Re-index required" sections are updated in PR-A; PR-B/C/D may amend wording but do not re-trigger. +`ONTOLOGY_VERSION` 13 → 14 lands in PR-A. **One** re-index across the sequence. README + `docs/AGENT-GUIDE.md` updated in PR-A. ## What this document does NOT cover -- Per-PR file-path / function-signature detail — that's the per-PR plan (`plans/PLAN-*.md`). -- Cursor task prompts — `plans/CURSOR-PROMPTS-*.md`. -- Out-of-scope proposes (TIER2-INCREMENTAL-REBUILD, RANKING-MICROSERVICE, ENHANCED-ROLE-RECOGNITION, INDEX-AUTO-MODE) — those are not in the merge sequence right now. When one becomes in-flight, this doc is updated. -- Review cycles inside a single propose or PR — see each PR's review threads. +- Per-PR deliverables — `plans/PLAN-*.md` +- Cursor handoffs — `plans/CURSOR-PROMPTS-*.md` +- Out-of-scope proposes (TIER2-INCREMENTAL-REBUILD, RANKING-MICROSERVICE, etc.) +- Intra-PR review threads ## Maintenance -This file is updated whenever: -- A new propose enters draft. -- A propose locks or its code PRs merge. -- The dependency graph changes. - -Stale rows are deleted when their code PRs land in `master`. After PR-D merges, this whole document collapses to "no proposes in flight" until the next one starts. +Update this file when a propose enters draft, locks, or its code PRs land. After PR-D merges, collapse to "no proposes in flight" until the next effort starts. diff --git a/propose/HINTS-V3-PROPOSE.md b/propose/HINTS-V3-PROPOSE.md index ab05119..49c9784 100644 --- a/propose/HINTS-V3-PROPOSE.md +++ b/propose/HINTS-V3-PROPOSE.md @@ -1,15 +1,16 @@ # HINTS-V3 — kind- and direction-aware empty-result hints driven by EDGE_SCHEMA **Status**: draft -**Author**: Dmitriy Teriaev + Perplexity Computer +**Author**: Dmitriy Teriaev **Date**: 2026-05-16 ## TL;DR - Replace the single generic empty-neighbors template `TPL_NEIGHBORS_EMPTY_KIND_CHECK = "0 results — check if the requested edge_types apply to this kind"` with a small family of kind- and direction-aware templates driven by `EDGE_SCHEMA` (introduced in `propose/SCHEMA-V2-PROPOSE.md` §3.4). - Each template fires by inspecting the subject node kind, the requested `direction`, and the requested `edge_types` against `EDGE_SCHEMA[edge].src` / `.dst` / `.typical_traversals` — no hardcoded edge-shape literals in `mcp_hints.py`. -- New emit-side input: hints v3 also reads `BROWNFIELD_RESOLVER_STRATEGY_SET` (added in SCHEMA-V2 PR-A) to fire a distinct *"absence may mean unresolved, not absent"* hint that complements (does not replace) the v2 `TPL_NEIGHBORS_FUZZY_STRATEGY` hint. -- Migration: 1 PR (= SCHEMA-V2 PR-D), gated on this propose locking and on SCHEMA-V2 PR-A through PR-C all merging first. Re-index is already required by SCHEMA-V2 (`ONTOLOGY_VERSION` 13 → 14); HINTS-V3 does not bump it again. +- New emit-side input: hints v3 reads `EdgeSpec.brownfield_resolver_sourced` (backed by `BROWNFIELD_RESOLVER_STRATEGY_SET` from SCHEMA-V2 PR-A) to fire a distinct *"absence may mean unresolved, not absent"* hint on empty results. That complements (does not replace) the v2 `TPL_NEIGHBORS_FUZZY_STRATEGY` hint on non-empty results. +- **Propose gate** (SCHEMA-V2 Decision 30): this file must exist as a **draft PR** before SCHEMA-V2 PR-A starts. **Implementation gate**: this propose must be **locked** before SCHEMA-V2 PR-D merges. PR-D runs after PR-A, PR-B, and PR-C are in `master`. +- Re-index is already required by SCHEMA-V2 (`ONTOLOGY_VERSION` 13 → 14); HINTS-V3 does not bump it again. - Goes away: `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (deleted). Stays: every existing v1/v2 template (DESCRIBE rollups, FIND, RESOLVE, fuzzy-strategy hint). - Non-obvious constraint: hints v3 must never recommend a dot-key edge label as a `neighbors()` argument (carry-over from v2 propose §7.x). All template recommendations are checked against the canonical edge list. @@ -22,18 +23,19 @@ The v1 empty-neighbors template is a placeholder: it tells the agent "your kind The frame rules out three temptations: - Hand-written per-edge templates ("for HTTP_CALLS, suggest DECLARES_CLIENT") — that's the bug v1 has. Knowledge lives in `EDGE_SCHEMA`, hints render it. -- Reasoning about why the result is empty (graph state, indexing, ranking) — out of scope. Hints v3 only handles structurally-impossible queries (wrong kind, wrong direction, wrong edge for kind) and brownfield-resolver absence. -- Cross-edge composition planning (multi-hop suggestions beyond the single canonical traversal stored in `EDGE_SCHEMA`) — out of scope. One canonical traversal per (subject_kind, edge, direction) tuple, sourced from `typical_traversals`. +- Reasoning about why the result is empty (graph state, indexing, ranking) — out of scope for structural templates. Hints v3 handles structurally-impossible queries (wrong kind, wrong direction, type-vs-method-level) plus a separate brownfield-resolver-absence template (see Principle 8). +- Cross-edge composition planning (multi-hop suggestions beyond the single canonical traversal stored in `EDGE_SCHEMA`) — out of scope. One canonical traversal per (subject role, edge) tuple, sourced from `typical_traversals`. ## §2 — Design principles 1. **`EDGE_SCHEMA` is the only source of edge-shape knowledge.** No edge name, src/dst kind, or traversal string appears as a literal in `mcp_hints.py` outside of test fixtures. 2. **One template per dimension of mismatch.** Subject-kind mismatch, direction mismatch, type-vs-method-level mismatch, and brownfield-resolver absence are four distinct templates, not one polymorphic one. 3. **Hints v3 never recommends a dot-key edge label.** Generator output is filtered against the canonical edge list before emission. -4. **Hints v3 reads `BROWNFIELD_RESOLVER_STRATEGY_SET` and `FUZZY_STRATEGY_SET` independently.** Membership in the fuzzy set fires the v2 fuzzy hint (unchanged); membership in the broader resolver set fires the new "may be unresolved" hint. The two can both fire when the edge is fuzzy-resolved by the brownfield resolver — they are not mutually exclusive. -5. **Templates carry the canonical traversal verbatim from `EDGE_SCHEMA[e].typical_traversals`.** No re-rendering or string editing of the traversal inside `mcp_hints.py`. -6. **Empty-result hints are advisory, capped at 5 total per output (v1 invariant), and priority-ordered.** Kind/direction hints sit at `PRIORITY_LEAF_FOLLOWUP=2` like the v1 template they replace. -7. **A subject node kind that already matches the edge schema does not get a kind-mismatch hint, even on empty result.** Hints v3 only fires when there is a *structural* reason to expect the query to fail — never on "the graph happens to have no rows." +4. **Fuzzy vs brownfield-resolver hints are branch-exclusive on the same `neighbors` call.** The v2 fuzzy hint fires only on **non-empty** results (per-row `strategy` in `FUZZY_STRATEGY_SET`). The v3 brownfield-absence hint fires only on **empty** results when `EdgeSpec.brownfield_resolver_sourced=True`. They never co-fire on one call. +5. **Templates carry the canonical traversal verbatim from `EDGE_SCHEMA[e].typical_traversals`.** No re-rendering or string editing of the traversal inside `mcp_hints.py`. Role-keyed entries (e.g. `type_subject`) are selected by a small helper; keys are defined in SCHEMA-V2 PR-A. +6. **Empty-result structural hints use `PRIORITY_META=1`.** Same priority as the v1 `TPL_NEIGHBORS_EMPTY_KIND_CHECK` and v2 `TPL_NEIGHBORS_FUZZY_STRATEGY` neighbours hints they sit beside. No new priority constant. +7. **Structural templates never fire on "graph happens to have no rows".** When kind, direction, and member-level are all correct, an empty result yields **no structural hint** (HV8, HV9, HV11). +8. **Brownfield-resolver absence is not a structural mismatch.** When kind and direction are correct but `brownfield_resolver_sourced=True`, empty results may still emit `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` — that is intentional (HV4, HV13, HV14) and does not violate Principle 7. ## §3 — The proposed surface @@ -54,8 +56,7 @@ TPL_NEIGHBORS_WRONG_DIRECTION = ( TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = ( "0 results — '{edge}' lives on methods, not on {subject_kind}. " - "Try: neighbors(['{id}'],'out',['DECLARES']) then " - "neighbors(member_ids,'{direction}',['{edge}'])" + "Try: {canonical_traversal}" ) TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = ( @@ -74,9 +75,9 @@ Hints v3 adds one generator function consumed by `neighbors`: ```python def neighbors_empty_hints( *, - subject_record: dict[str, Any], # the node looked up by id + subject_record: dict[str, Any], # origin node row (see §3.6) requested_edge_types: list[str], - requested_direction: Literal["in", "out", "any"], + requested_direction: Literal["in", "out"], ) -> list[tuple[int, str]]: """Emit at most one structural mismatch hint per requested edge, plus a brownfield-resolver hint if any requested edge is brownfield-resolved. @@ -87,41 +88,76 @@ def neighbors_empty_hints( The function: 1. Reads `EDGE_SCHEMA[edge]` for each requested edge type. -2. Compares `subject_record`'s node label + `data.kind` against `EdgeSpec.src` (for `direction="out"`) or `.dst` (for `direction="in"`); `direction="any"` checks both. -3. Picks the first applicable mismatch template per edge (subject-kind > direction > type-level), substitutes from the spec, and assigns priority `PRIORITY_LEAF_FOLLOWUP=2`. -4. If any requested edge has `EdgeSpec.brownfield_resolver_sourced=True`, emits `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` once (deduped if multiple such edges) at priority `PRIORITY_LEAF_FOLLOWUP=2`. +2. Resolves the subject's **node label** (`Symbol`, `Client`, `Route`, `Producer`) from `subject_record` (not `symbol_kind` alone). +3. For each edge, evaluates structural templates in **fixed order** (first match wins): + - **Alien kind** — subject label matches **neither** `EdgeSpec.src` **nor** `EdgeSpec.dst` → `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` with `canonical_traversal` from `typical_traversals` (role key chosen by helper; see Decision 8). + - **Wrong direction** — subject label matches the endpoint for the **opposite** direction (`out` expects `src`, `in` expects `dst`; opposite means matches `dst` on `out` or `src` on `in`) → `TPL_NEIGHBORS_WRONG_DIRECTION` with `correct_dir` set to the direction where the subject is a valid endpoint. + - **Type-level requery** — subject is a `Symbol` with `symbol_kind ∈ _TYPE_SYMBOL_KINDS` and `EdgeSpec.member_only=True` → `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` with `canonical_traversal` from `typical_traversals["type_subject"]`. +4. Assigns `PRIORITY_META` to every structural template from step 3. +5. If any requested edge has `EdgeSpec.brownfield_resolver_sourced=True`, emits `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` once (deduped across edges) at `PRIORITY_META`. This runs even when step 3 emitted nothing (HV13/HV14). -The function does **not** emit the v2 fuzzy-strategy hint — that path stays in the existing non-empty-result hint generator (since `FUZZY_STRATEGY_SET` membership is per-row, not per-edge-schema). +The function does **not** emit the v2 fuzzy-strategy hint — that path stays in the existing non-empty-result branch (per-row `strategy`). ### §3.3 — Hint emission rules summary -| Trigger | Template | Priority | -|---|---|---| -| Subject's node kind is not in `EdgeSpec.src` (for `direction="out"`) or `.dst` (for `direction="in"`) | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` | 2 | -| Subject's node kind matches the opposite endpoint of the requested direction | `TPL_NEIGHBORS_WRONG_DIRECTION` | 2 | -| Subject is a Symbol with `symbol_kind ∈ _TYPE_SYMBOL_KINDS` and the requested edge lives on methods (i.e., `src` or `dst` is `Symbol` but `EdgeSpec.member_only=True` — see §3.4) | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` | 2 | -| Any requested edge has `EdgeSpec.brownfield_resolver_sourced=True` | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` | 2 | +| Order | Trigger | Template | Priority | +|---|---|---|---| +| 1 | Subject node label matches neither `EdgeSpec.src` nor `EdgeSpec.dst` | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` | `PRIORITY_META` | +| 2 | Subject label matches the opposite endpoint for the requested direction | `TPL_NEIGHBORS_WRONG_DIRECTION` | `PRIORITY_META` | +| 3 | Subject is a type-level `Symbol` and `EdgeSpec.member_only=True` | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` | `PRIORITY_META` | +| (parallel) | Any requested edge has `EdgeSpec.brownfield_resolver_sourced=True` on an empty result | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` | `PRIORITY_META` | -At most one of the first three fires per edge (in the order shown). The brownfield hint can co-fire with any of them. +At most one of rows 1–3 fires per requested edge. Row 4 can co-fire with any of 1–3 (deduped once per output). ### §3.4 — Required `EdgeSpec` field additions (preview) For hints v3 to do its job without literal edge-shape knowledge, `EdgeSpec` (defined in SCHEMA-V2 §3.4 / Appendix A) must carry one bit not strictly required by SCHEMA-V2 alone: -- `member_only: bool` — True iff the edge attaches only to `Symbol` rows whose `symbol_kind ∈ {method, constructor}` (e.g., `DECLARES_CLIENT`, `DECLARES_PRODUCER`, `EXPOSES`, `HTTP_CALLS` post-flip via Client which is declared by methods, `ASYNC_CALLS` post-flip via Producer). +- `member_only: bool` — default `False`. Set `True` when the edge is only meaningfully queried from **method-level** `Symbol` rows (`symbol_kind ∈ {method, constructor}`), and a **type-level** `Symbol` (`class`, `interface`, `enum`, `record`, `annotation`) should get `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` instead of a kind-mismatch hint. Set on: `DECLARES_CLIENT`, `DECLARES_PRODUCER`, `EXPOSES`, `OVERRIDES`, `CALLS`. **Do not** set on post-flip `HTTP_CALLS` / `ASYNC_CALLS` (`Client`/`Producer` endpoints) — method subjects asking those edges hit row 1 (`WRONG_SUBJECT_KIND`) with the `member_subject` traversal from `typical_traversals`. -`member_only` is a hint-engine-only field. The DDL-consistency CI check does not assert it (kuzu has no notion). It is set in `EDGE_SCHEMA` and read by `neighbors_empty_hints`. **This is the only `EdgeSpec` field added solely for hints v3.** +`member_only` is hint-engine-only. The DDL-consistency CI check does not assert it. Prefer landing the field in SCHEMA-V2 PR-A; PR-D adds it if PR-A does not. -If SCHEMA-V2's `EdgeSpec` does not yet include `member_only`, this propose's PR-D adds it. +### §3.5 — `typical_traversals` shape (PR-A contract) -### §3.5 — What does NOT change +SCHEMA-V2 PR-A finalizes `typical_traversals` as a **mapping** from subject-role key to traversal string, for example: -- `TPL_DESCRIBE_*` family (v1, type/method/route/client describe rollups): unchanged. +```python +typical_traversals={ + "type_subject": "neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['{edge}'])", + "member_subject": "neighbors(['{id}'],'out',['DECLARES_CLIENT']) then neighbors(client_ids,'out',['HTTP_CALLS'])", + "alien_subject": "...", # per-edge; used by WRONG_SUBJECT_KIND +} +``` + +Hints v3 selects the key via a small helper (`type_subject` / `member_subject` / default). PR-A populates every edge; test HV19 asserts coverage. + +### §3.6 — Neighbors hint payload (PR-D wiring) + +Today `neighbors_v2` passes only `results` and `requested_edge_types` into `generate_hints`. PR-D **must** extend the payload: + +```python +neigh_payload = { + "success": True, + "results": [...], + "requested_edge_types": list(labels), + "requested_direction": direction, # Literal["in", "out"] + "origin_id": origins[0], # first origin when ids is a list + "subject_record": , # from _load_node_record(g, origin_id, kind) +} +``` + +- **`direction`**: `neighbors` already requires `in` | `out` (no `any`); the generator mirrors that. +- **Multi-id requests**: when `ids` is a list, hint generation uses **`origins[0]`** only. Structural hints describe that subject; aggregated `results` may include hops from other origins (pre-existing behaviour). Document in `MCP_HINTS_FIELD_DESCRIPTION` if needed. +- **`generate_hints("neighbors", …)`** calls `neighbors_empty_hints` when `results` is empty and `requested_edge_types` is non-empty; merges returned pairs before `finalize_hint_list`. + +### §3.7 — What does NOT change + +- `TPL_DESCRIBE_*` family (v1): unchanged. - `TPL_FIND_*`, `TPL_RESOLVE_*`, `TPL_SEARCH_WEAK`: unchanged. -- `TPL_NEIGHBORS_FUZZY_STRATEGY` (v2 fuzzy hint): unchanged. -- Priority constants, `finalize_hint_list`, the `MCP_HINTS_FIELD_DESCRIPTION` schema description: unchanged. +- `TPL_NEIGHBORS_FUZZY_STRATEGY` (v2): unchanged. +- Priority constants (except empty-neighbours structural hints stay at `PRIORITY_META`), `finalize_hint_list`, `MCP_HINTS_FIELD_DESCRIPTION`: unchanged except PR-D may append one sentence on multi-id hint subject. - The 5-hint output cap: unchanged. -- No new MCP tool, no new tool argument. `neighbors` already accepts the inputs hints v3 needs. +- No new MCP tool arguments. ## §4 — Use-case re-walk @@ -129,48 +165,53 @@ Each row references the SCHEMA-V2 use-case re-walk (§4 of that propose) where a | # | Use case | Subject | Request | Pre-v3 hint | Post-v3 hint | |---|---|---|---|---|---| -| HV1 (= SCHEMA-V2 UC2) | Class-level subject, asks `DECLARES_CLIENT` outbound | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['DECLARES_CLIENT'])` | `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (generic) | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` — `neighbors(['{id}'],'out',['DECLARES'])` then `neighbors(member_ids,'out',['DECLARES_CLIENT'])` | -| HV2 (= SCHEMA-V2 UC3) | Method-level subject, asks `HTTP_CALLS` outbound (post-flip) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['HTTP_CALLS'])` | `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (generic) | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — `'HTTP_CALLS' connects Client → Route; this is a Symbol. Try: neighbors(['{id}'],'out',['DECLARES_CLIENT']) then neighbors(client_ids,'out',['HTTP_CALLS'])` | -| HV3 | Method-level subject, asks `ASYNC_CALLS` outbound (post-flip) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['ASYNC_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` pointing at `DECLARES_PRODUCER` then `ASYNC_CALLS` | -| HV4 (= SCHEMA-V2 UC15) | Producer subject, no resolved topic, asks for downstream | `Producer{}` | `neighbors([producer_id], 'out', ['ASYNC_CALLS'])` returning `[]` | n/a (Producer didn't exist) | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` — "ASYNC_CALLS edges are brownfield-resolver-emitted; absence may mean unresolved" | -| HV5 (= SCHEMA-V2 UC17) | Producer subject, asks `ASYNC_CALLS` inbound | `Producer{}` | `neighbors([producer_id], 'in', ['ASYNC_CALLS'])` | n/a | `TPL_NEIGHBORS_WRONG_DIRECTION` — `'ASYNC_CALLS' is Producer → Route; you requested direction='in'. Try direction='out'.` | -| HV6 | Client subject, asks `HTTP_CALLS` inbound | `Client{}` | `neighbors([client_id], 'in', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_DIRECTION` — same shape as HV5 | -| HV7 | Route subject, asks `HTTP_CALLS` outbound | `Route{}` | `neighbors([route_id], 'out', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_DIRECTION` | -| HV8 | Symbol method, asks `EXPOSES` outbound — empty because not a controller method | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['EXPOSES'])` returning `[]` | generic | **No hint** — subject kind matches `EXPOSES.src=Symbol`, direction is correct, edge is method-level and subject is a method. The empty result is *graph state*, not structural. (Principle 7.) | -| HV9 | Symbol method, asks `DECLARES_CLIENT` outbound — empty because the method has no `@CodebaseHttpClient` | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['DECLARES_CLIENT'])` returning `[]` | generic | **No hint** — kind+direction+member-level all correct. Graph state, not structural. | -| HV10 (= SCHEMA-V2 UC22) | Class-level subject, asks `HTTP_CALLS` outbound (post-flip) | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — `'HTTP_CALLS' connects Client → Route; this is a Symbol(class).` Plus, because the subject is a *type*-kind Symbol, the canonical traversal from `EDGE_SCHEMA["HTTP_CALLS"].typical_traversals[type_subject]` is substituted. | -| HV11 | Method subject, asks `OVERRIDES` outbound, no superclass method to override | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['OVERRIDES'])` returning `[]` | generic | **No hint** — structural query is fine. | -| HV12 | Annotation symbol, asks `EXTENDS` outbound | `Symbol{symbol_kind=annotation}` | `neighbors([ann_id], 'out', ['EXTENDS'])` returning `[]` | generic | If `EXTENDS.src ∋ Symbol` but `member_only=False` and kind is fine: no hint. If schema records that annotations cannot extend, `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` fires. (Depends on `EXTENDS` `EdgeSpec` finalization; documented in SCHEMA-V2 PR-A.) | -| HV13 | Client subject, asks `HTTP_CALLS` outbound — empty because the Client's `target_path` did not match any route (brownfield resolver failed) | `Client{}` | `neighbors([client_id], 'out', ['HTTP_CALLS'])` returning `[]` | generic | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED`. Subject kind + direction + member-level are all correct, but `EdgeSpec.brownfield_resolver_sourced=True`. | -| HV14 | Producer subject, asks `ASYNC_CALLS` outbound — empty because the Producer's broker is unknown | `Producer{}` | `neighbors([producer_id], 'out', ['ASYNC_CALLS'])` returning `[]` | n/a | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` | -| HV15 | Method subject, asks both `HTTP_CALLS` and `DECLARES_CLIENT` outbound | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['HTTP_CALLS', 'DECLARES_CLIENT'])` returning HTTP `[]`, DECLARES_CLIENT `[]` | one generic hint | One `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` hint for `HTTP_CALLS`; **no hint** for `DECLARES_CLIENT` (HV9 logic). Two requested edges, one structural mismatch, one structurally-fine empty. | -| HV16 | Method subject, asks `ASYNC_CALLS` outbound; mixed brownfield-strategy edges actually present (non-empty result) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['ASYNC_CALLS'])` returning N edges | v2 fuzzy hint fires if any edge has fuzzy strategy | Same as v2 — `neighbors_empty_hints` is not invoked on non-empty results. v2 fuzzy hint logic unchanged. | -| HV17 | Class-level subject, asks `EXPOSES` outbound | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['EXPOSES'])` | generic | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` — class is wrong member level for `EXPOSES`, suggest `DECLARES` then re-query. | -| HV18 | Mixed-direction `direction='any'` request on a non-fitting kind | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'any', ['HTTP_CALLS'])` returning `[]` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — neither `HTTP_CALLS.src` nor `.dst` matches `Symbol`; the canonical traversal from `typical_traversals` is suggested. | -| HV19 | `EDGE_SCHEMA` reader cross-check from a CI test | n/a | n/a | n/a | Test asserts every edge in `EDGE_SCHEMA` is reachable via at least one of the four templates above; no edge falls through to "no applicable hint generator". | -| HV20 | Future edge added (e.g. `INHERITS_FROM_FRAMEWORK`) | varies | varies | requires hand-edit | Adds entry to `EDGE_SCHEMA`; hints v3 picks it up automatically with no code change. | +| HV1 (= SCHEMA-V2 UC2) | Class-level subject, asks `DECLARES_CLIENT` outbound | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['DECLARES_CLIENT'])` | `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (generic) | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` — `canonical_traversal` from `typical_traversals["type_subject"]` | +| HV2 (= SCHEMA-V2 UC3) | Method-level subject, asks `HTTP_CALLS` outbound (post-flip) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — `canonical_traversal` from `typical_traversals["member_subject"]` (DECLARES_CLIENT → HTTP_CALLS chain) | +| HV3 | Method-level subject, asks `ASYNC_CALLS` outbound (post-flip) | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['ASYNC_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — `member_subject` traversal (DECLARES_PRODUCER → ASYNC_CALLS) | +| HV4 (= SCHEMA-V2 UC15) | Producer subject, correct direction, empty graph | `Producer{}` | `neighbors([producer_id], 'out', ['ASYNC_CALLS'])` returning `[]` | n/a | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` only (no structural row; HV8-style) | +| HV5 (= SCHEMA-V2 UC17) | Producer subject, asks `ASYNC_CALLS` inbound | `Producer{}` | `neighbors([producer_id], 'in', ['ASYNC_CALLS'])` | n/a | `TPL_NEIGHBORS_WRONG_DIRECTION` — Producer matches `src`, not `dst`; row 2 | +| HV6 | Client subject, asks `HTTP_CALLS` inbound | `Client{}` | `neighbors([client_id], 'in', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_DIRECTION` — Client matches `src`, not `dst`; row 2 | +| HV7 | Route subject, asks `HTTP_CALLS` outbound | `Route{}` | `neighbors([route_id], 'out', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_DIRECTION` — Route matches `dst`, not `src`; row 2 | +| HV8 | Symbol method, asks `EXPOSES` outbound — not a controller | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['EXPOSES'])` returning `[]` | generic | **No structural hint** — row 3 does not apply (method-level subject); graph state | +| HV9 | Symbol method, asks `DECLARES_CLIENT` outbound — no client declared | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['DECLARES_CLIENT'])` returning `[]` | generic | **No structural hint** — structurally valid query | +| HV10 (= SCHEMA-V2 UC22) | Class-level subject, asks `HTTP_CALLS` outbound (post-flip) | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['HTTP_CALLS'])` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — class label `Symbol` matches neither `Client` nor `Route`; `alien_subject` / default traversal | +| HV11 | Method subject, asks `OVERRIDES` outbound, nothing to override | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['OVERRIDES'])` returning `[]` | generic | **No structural hint** | +| HV12 | Annotation symbol, asks `EXTENDS` outbound | `Symbol{symbol_kind=annotation}` | `neighbors([ann_id], 'out', ['EXTENDS'])` returning `[]` | generic | **No structural hint** when `member_only=False` and annotation is a valid `Symbol` endpoint; otherwise row 1 if PR-A excludes annotations from `EXTENDS.src` | +| HV13 | Client subject, asks `HTTP_CALLS` outbound — resolver found no route | `Client{}` | `neighbors([client_id], 'out', ['HTTP_CALLS'])` returning `[]` | generic | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` only | +| HV14 | Producer subject, asks `ASYNC_CALLS` outbound — unresolved broker | `Producer{}` | `neighbors([producer_id], 'out', ['ASYNC_CALLS'])` returning `[]` | n/a | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` only | +| HV15 | Method subject, asks both `HTTP_CALLS` and `DECLARES_CLIENT` outbound | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['HTTP_CALLS', 'DECLARES_CLIENT'])` | one generic hint | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` for `HTTP_CALLS` only; no hint for `DECLARES_CLIENT` (HV9) | +| HV16 | Method subject, `ASYNC_CALLS` returns edges with fuzzy strategy | `Symbol{symbol_kind=method}` | non-empty `neighbors(..., ['ASYNC_CALLS'])` | v2 fuzzy hint | v2 fuzzy hint; `neighbors_empty_hints` not called | +| HV17 | Class-level subject, asks `EXPOSES` outbound | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['EXPOSES'])` | generic | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` — row 3 | +| HV18 | Route subject, asks `DECLARES` outbound | `Route{}` | `neighbors([route_id], 'out', ['DECLARES'])` returning `[]` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — Route matches neither Symbol endpoint; row 1 | +| HV19 | CI: `EDGE_SCHEMA` coverage | n/a | n/a | n/a | For **each** edge `e` in `EDGE_SCHEMA`, ∃ a `(subject_node_label, direction)` pair such that `neighbors_empty_hints` would emit **at least one** of rows 1–3 or row 4 for a synthetic empty result. Does **not** require every empty query to hint. | +| HV20 | Future edge added | varies | varies | hand-edit `EDGE_SCHEMA` | Hints follow schema automatically; HV19 fails CI until traversals + `member_only` are populated | ### Awkward cases surfaced -- **HV12** depends on the final shape of `EdgeSpec` for `EXTENDS` — whether annotations are excluded from `src`. The propose doesn't lock that; SCHEMA-V2 PR-A is the right place. HINTS-V3 just consumes whatever PR-A produces. -- **HV15** demonstrates that the per-edge fan-out is not a problem in practice because the 5-hint cap and the dedup pass collapse redundant brownfield-resolver hints. -- **HV13/HV14** are the case the v2 fuzzy hint doesn't cover: the brownfield resolver tried, found nothing, emitted no edge — there is no `attrs.strategy` to inspect because there is no edge. The dedicated v3 brownfield template handles this asymmetry. +- **HV12** consumes whatever PR-A locks for `EXTENDS.src` (annotation eligibility). +- **HV15** per-edge fan-out is bounded by the 5-hint cap and brownfield dedupe. +- **HV13/HV14** are why row 4 exists: resolver-sourced edges with no emitted row have no `attrs.strategy` to drive the v2 fuzzy hint. ## §5 — What this deliberately does NOT do | Question / feature | Why we skip it | |---|---| -| Generate per-edge bespoke prose (e.g. "Did you mean DECLARES_CLIENT? Here's why HTTP works this way…") | Out of scope. Hints are road signs, not tutorials. `MCP_HINTS_FIELD_DESCRIPTION` describes the contract. | -| Cross-edge composition planner (multi-hop suggestions beyond the canonical traversal in `typical_traversals`) | Out of scope. One canonical traversal per (edge, subject-kind, direction) lives in `EDGE_SCHEMA`. Composition belongs to the agent. | -| Reason about graph state (ranking, indexing, freshness) at empty-result time | Out of scope. Hints v3 only handles structurally-impossible queries and brownfield-resolver absence. | -| Add new templates that recommend dot-key edge labels (e.g. `DECLARES.DECLARES_CLIENT`) as `neighbors()` arguments | Forbidden by v2 invariant (`MCP_HINTS_FIELD_DESCRIPTION` last sentence). Generator output is checked. | -| Cache hint outputs across queries | No state. Hints are pure functions of subject + request + `EDGE_SCHEMA`. | -| Add a hint emission for `find` / `resolve` / `describe` empty results that's edge-schema-aware | Out of scope. Those tools already have dedicated hint families (`TPL_FIND_EMPTY_RESOLVE`, `TPL_RESOLVE_NONE_*`, `TPL_DESCRIBE_*`). | -| Localized hint text | Out of scope. English only, consistent with the rest of `mcp_hints.py`. | +| Per-edge bespoke prose | Road signs only; `MCP_HINTS_FIELD_DESCRIPTION` holds contract prose | +| Multi-hop planning beyond `typical_traversals` | One canonical traversal per role key; agent composes further | +| Graph-state reasoning in structural templates | Principle 7; brownfield row 4 is the sole empty-result exception | +| Dot-key edge labels in recommendations | v2 invariant; post-filter in PR-D tests | +| Hint caching | Pure function of payload + `EDGE_SCHEMA` | +| Edge-schema hints on `find` / `resolve` / `describe` | Those tools already have hint families | +| Localization | English only | ## §6 — Migration plan — 1 PR (= SCHEMA-V2 PR-D) -**Merge gate**: this propose must be **locked** before SCHEMA-V2 PR-A merges (Decision 30 in SCHEMA-V2-PROPOSE.md). The implementation PR (PR-D) must merge **after** SCHEMA-V2 PR-A, PR-B, and PR-C are all in master, because the templates substitute `src_kind`/`dst_kind` from the post-flip `EDGE_SCHEMA`. +**Propose gates** (aligned with SCHEMA-V2 Decision 30): + +- **Draft PR** (#154): must be merged to `master` before SCHEMA-V2 **PR-A** starts implementation. +- **Locked**: this propose's `Status` must be `locked` before SCHEMA-V2 **PR-D** merges. + +**PR-D gates**: merges only after PR-A, PR-B, and PR-C are in `master` (post-flip `EDGE_SCHEMA`). ### PR-D — kind/direction-aware empty-result hints @@ -179,78 +220,63 @@ Each row references the SCHEMA-V2 use-case re-walk (§4 of that propose) where a **Purpose**: - Delete `TPL_NEIGHBORS_EMPTY_KIND_CHECK`. -- Add the four templates listed in §3.1. -- Add `EdgeSpec.member_only: bool` to `EDGE_SCHEMA` entries (or fold into PR-A — see §7 Decision 6). -- Add `neighbors_empty_hints(...)` generator in `mcp_hints.py`. -- Wire it into the `neighbors` empty-result branch in `mcp_v2.py`. -- Add a dot-key edge label filter (assertion or post-filter) to enforce the v2 invariant. +- Add the four templates in §3.1. +- Add `neighbors_empty_hints(...)` and `typical_traversal_for(...)` helper in `mcp_hints.py`. +- Extend `neighbors_v2` hint payload per §3.6; wire empty branch in `generate_hints`. +- Add `EdgeSpec.member_only` to `EDGE_SCHEMA` if PR-A did not. +- Dot-key edge-label post-filter + tests. -**Test summary**: named scenarios in `tests/test_mcp_hints.py` covering HV1, HV2, HV3, HV4, HV5, HV6, HV7, HV8 (no-hint case), HV10, HV13, HV15, HV17, HV18, HV19; v2-regression scenarios asserting `TPL_NEIGHBORS_FUZZY_STRATEGY` still fires on non-empty fuzzy-resolved results; `MCP_HINTS_FIELD_DESCRIPTION` invariant test that no emitted hint string contains a dot in an edge-label position. +**Test summary** (`tests/test_mcp_hints.py`): HV1–HV19 by name (`test_hints_hv{N}_...`); explicit no-hint cases HV8, HV9, HV11, HV12 (when `member_only=False`); HV15 multi-edge; HV16 v2 fuzzy regression on non-empty; HV19 schema coverage; dot-in-edge-types invariant on rendered hints. ## §7 — Decisions taken (no longer open) -1. **`TPL_NEIGHBORS_EMPTY_KIND_CHECK` is deleted.** It is replaced by the family in §3.1, not extended. -2. **The four mismatch dimensions are subject-kind, direction, type-vs-method-level, and brownfield-resolver absence.** One template each. -3. **At most one of the first three templates fires per requested edge.** Order: subject-kind > direction > type-level. -4. **`TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` can co-fire with any of the first three, but is deduped across all requested edges.** -5. **Priority of all four new templates is `PRIORITY_LEAF_FOLLOWUP=2`** — same as the v1 template they replace. No new priority constant. -6. **`EdgeSpec.member_only: bool` is added to `EDGE_SCHEMA`.** Strictly hint-engine-only field. CI DDL-consistency check ignores it. It is preferable to land this field in SCHEMA-V2 PR-A (alongside other `EdgeSpec` fields) rather than in PR-D, but PR-D will add it if PR-A does not. -7. **Canonical traversal strings come verbatim from `EDGE_SCHEMA[e].typical_traversals`.** Hints v3 does not synthesize traversal strings. -8. **`typical_traversals` may be keyed by subject role** (e.g. `type_subject`, `member_subject`) where the canonical traversal differs by subject kind. Exact key set is finalized by SCHEMA-V2 PR-A. -9. **Hints v3 reads `BROWNFIELD_RESOLVER_STRATEGY_SET` via `EdgeSpec.brownfield_resolver_sourced`,** not by enumerating the set itself. The set is only used inside the per-row `_any_fuzzy_strategy`-style helper for the v2 fuzzy hint. -10. **No `member_only=True` edge between two non-Symbol nodes is permitted in `EDGE_SCHEMA`.** This invariant is asserted by a unit test in PR-A or PR-D. -11. **The 5-hint output cap (v1 invariant) is unchanged.** `finalize_hint_list` is reused. -12. **HINTS-V3 ships as SCHEMA-V2 PR-D, not as a separate code PR sequence.** The propose lives in its own PR but the implementation is one of the four schema-v2 code PRs. -13. **`mcp_hints.py` imports `EDGE_SCHEMA` from `java_ontology`.** No copy. No selective re-export. -14. **No back-compat alias for `TPL_NEIGHBORS_EMPTY_KIND_CHECK`.** Per locked repo rule "Breaking changes allowed; no active users." +1. **`TPL_NEIGHBORS_EMPTY_KIND_CHECK` is deleted**, not extended. +2. **Four templates**: alien kind, wrong direction, type-level requery, brownfield-resolver absence. +3. **Structural evaluation order per edge**: alien kind → wrong direction → type-level (first match wins). +4. **Row 4 (brownfield) dedupes across requested edges** and may co-fire with a structural row. +5. **All new empty-neighbour templates use `PRIORITY_META=1`**, matching the v1 empty template they replace. +6. **`member_only` semantics** per §3.4; default `False`; never `True` on `Client`/`Producer` endpoint edges. +7. **Traversals come only from `typical_traversals`**; type-level uses `"type_subject"` key. +8. **`typical_traversals` is a role-keyed map** finalized in SCHEMA-V2 PR-A (§3.5). +9. **Brownfield membership is read via `EdgeSpec.brownfield_resolver_sourced`**, not by re-walking `BROWNFIELD_RESOLVER_STRATEGY_SET` in the empty path. +10. **`member_only=True` only on Symbol–Symbol (or Symbol–Route) edges** listed in §3.4; unit test in PR-A or PR-D. +11. **5-hint cap unchanged**; `finalize_hint_list` reused. +12. **Implementation is SCHEMA-V2 PR-D**; this propose is a separate doc PR. +13. **`mcp_hints.py` imports `EDGE_SCHEMA` from `java_ontology`** — no copy. +14. **No back-compat alias** for `TPL_NEIGHBORS_EMPTY_KIND_CHECK`. +15. **Propose gate = draft PR before PR-A; lock before PR-D** — matches SCHEMA-V2 Decision 30. ## §8 — Risks and how we mitigate | Risk | Mitigation | |---|---| -| `EDGE_SCHEMA` doesn't carry enough information for a useful canonical traversal on every (edge, subject-kind) pair | `typical_traversals` is keyed by subject role. Test HV19 asserts every edge in `EDGE_SCHEMA` produces at least one applicable template + traversal for at least one realistic subject-kind. | -| A template fires spuriously on a structurally-correct empty (HV8/HV9/HV11 false positives) | Principle 7 + named scenarios HV8, HV9, HV11 in PR-D test suite explicitly cover this. The mismatch templates require *structural* impossibility — kind, direction, or member-level wrong — never just "empty rows". | -| Dot-key edge labels leak into a hint recommendation despite the v2 invariant | Post-filter on rendered hints checks for `\.` in any single-quoted edge_types list and raises in tests. | -| `EdgeSpec.member_only` becomes ambiguous for edges like `EXTENDS` that can connect any Symbol kind | `member_only=False` is the default. We only set `True` on edges whose `src` and `dst` are unambiguously method-level (`DECLARES_CLIENT`, `DECLARES_PRODUCER`, `EXPOSES`, `OVERRIDES`, `CALLS`). | -| Hints v3 lands before SCHEMA-V2 PR-A/B/C, references nonexistent `Client`/`Producer` shapes | Gating: PR-D merge-blocks behind PR-C (declared in this propose §6 and in SCHEMA-V2 §6). | -| `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` duplicates `TPL_NEIGHBORS_FUZZY_STRATEGY` on edges that are both brownfield-resolved and fuzzy-strategy | They cover different axes: one fires on empty results from a brownfield-resolved edge; the other on non-empty results that contain fuzzy-strategy rows. Empty + non-empty are exclusive branches of `neighbors` post-processing; both hints cannot fire on the same call. Test HV4 + HV16 cover the two branches. | -| Future edge additions break HINTS-V3 silently | Test HV19 (every edge in `EDGE_SCHEMA` has at least one template path) is a CI invariant. Adding an edge to `EDGE_SCHEMA` without a covering template causes the test to fail. | - -## Appendix A — Concrete template strings (Python) - -```python -# In mcp_hints.py, replacing the section currently containing TPL_NEIGHBORS_EMPTY_KIND_CHECK. - -TPL_NEIGHBORS_WRONG_SUBJECT_KIND = ( - "0 results — '{edge}' connects {src_kind} → {dst_kind}; " - "this is a {subject_kind}. Try: {canonical_traversal}" -) +| Missing traversal for some `(edge, role)` | PR-A populates `typical_traversals`; HV19 CI | +| False-positive structural hints on valid empties | HV8, HV9, HV11; three-step order | +| Dot-key labels in hint text | Post-filter + test | +| `member_only` ambiguity on `EXTENDS` | Default `False`; HV12 | +| PR-D before post-flip schema | PR-D gated on PR-C | +| Brownfield vs fuzzy duplication | Principle 4; HV4/HV16 | +| Silent breakage on new edges | HV19 | -TPL_NEIGHBORS_WRONG_DIRECTION = ( - "0 results — '{edge}' is {src_kind} → {dst_kind}; " - "you requested direction='{requested_dir}'. Try direction='{correct_dir}'." -) +## Appendix A — Traceability -TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = ( - "0 results — '{edge}' lives on methods, not on {subject_kind}. " - "Try: neighbors(['{id}'],'out',['DECLARES']) then " - "neighbors(member_ids,'{direction}',['{edge}'])" -) +**Review-1 (2026-05-16)** — aligned with SCHEMA-V2 Decision 30 and current `mcp_hints.py` / `neighbors_v2`: -TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = ( - "edges on '{edge}' are emitted by the brownfield resolver — " - "absence here may mean unresolved (no matching annotation/target), " - "not absent from the codebase" -) -``` - -## Appendix B — Traceability - -This is a first-draft propose; no revisions yet. If a reviewer changes the design, this section will list **what stayed unchanged** and **what changed and why**. +| Change | Why | +|---|---| +| Three-step structural order (alien → wrong direction → type-level) | HV5/HV6 were unreachable under old "check requested endpoint only" rule | +| `PRIORITY_META`, not `PRIORITY_LEAF_FOLLOWUP` | Matches shipped v1/v2 empty-neighbors priority | +| `TYPE_LEVEL_REQUERY` uses `{canonical_traversal}` only | Principle 1 — no `DECLARES` literal in `mcp_hints.py` | +| `member_only` scoped to Symbol method-level edges only | Removed contradiction with post-flip HTTP/ASYNC Client/Producer endpoints | +| §3.6 hint payload + multi-id rule | `neighbors_v2` does not pass subject/direction today | +| Propose gate: draft before PR-A, lock before PR-D | Was stricter than SCHEMA-V2 Decision 30 | +| Dropped `direction='any'` / HV18 replaced | API is `in` \| `out` only | +| HV19 clarified as ∃ coverage per edge, not ∀ empty queries | Compatible with HV8/HV9/HV11 | +| Principle 8 for brownfield empty hints | Explicit exception to structural-only framing | +| Removed duplicate template appendix | Templates live in §3.1 only | **Cross-propose references**: -- Consumes `EDGE_SCHEMA` from `propose/SCHEMA-V2-PROPOSE.md` §3.4 (locked). -- Consumes `BROWNFIELD_RESOLVER_STRATEGY_SET` from `propose/SCHEMA-V2-PROPOSE.md` §3.11 / Decision 28 (locked). -- Implements `propose/SCHEMA-V2-PROPOSE.md` §3.12 (preview) and PR-D §6 (gating). -- Builds on `propose/completed/HINTS-V2-PROPOSE.md` §7.x (5-hint cap, dot-key edge-label invariant, fuzzy-strategy hint) — unchanged. -- Builds on `propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md` Appendix A (v1 catalogue) — unchanged except `TPL_NEIGHBORS_EMPTY_KIND_CHECK` is deleted. + +- `propose/SCHEMA-V2-PROPOSE.md` §3.4, §3.11, Decision 28–30, PR-D §6 (locked via #151). +- `propose/completed/HINTS-V2-PROPOSE.md` — fuzzy hint, dot-key invariant (unchanged). +- `propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md` — v1 catalogue except `TPL_NEIGHBORS_EMPTY_KIND_CHECK` deleted. From 9160f3c8633cdebd21eb9dddd156f02f33a84c08 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 17:31:52 +0300 Subject: [PATCH 3/3] clarify hints-v3 propose gate wording and fix HV16 scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TL;DR and Decision 15 now match §6: merged to master before PR-A. HV16 uses Client + HTTP_CALLS post-flip (method Symbol cannot be non-empty on ASYNC_CALLS after the flip). Co-authored-by: Cursor --- propose/HINTS-V3-PROPOSE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/propose/HINTS-V3-PROPOSE.md b/propose/HINTS-V3-PROPOSE.md index 49c9784..b2acc7a 100644 --- a/propose/HINTS-V3-PROPOSE.md +++ b/propose/HINTS-V3-PROPOSE.md @@ -9,7 +9,7 @@ - Replace the single generic empty-neighbors template `TPL_NEIGHBORS_EMPTY_KIND_CHECK = "0 results — check if the requested edge_types apply to this kind"` with a small family of kind- and direction-aware templates driven by `EDGE_SCHEMA` (introduced in `propose/SCHEMA-V2-PROPOSE.md` §3.4). - Each template fires by inspecting the subject node kind, the requested `direction`, and the requested `edge_types` against `EDGE_SCHEMA[edge].src` / `.dst` / `.typical_traversals` — no hardcoded edge-shape literals in `mcp_hints.py`. - New emit-side input: hints v3 reads `EdgeSpec.brownfield_resolver_sourced` (backed by `BROWNFIELD_RESOLVER_STRATEGY_SET` from SCHEMA-V2 PR-A) to fire a distinct *"absence may mean unresolved, not absent"* hint on empty results. That complements (does not replace) the v2 `TPL_NEIGHBORS_FUZZY_STRATEGY` hint on non-empty results. -- **Propose gate** (SCHEMA-V2 Decision 30): this file must exist as a **draft PR** before SCHEMA-V2 PR-A starts. **Implementation gate**: this propose must be **locked** before SCHEMA-V2 PR-D merges. PR-D runs after PR-A, PR-B, and PR-C are in `master`. +- **Propose gate** (SCHEMA-V2 Decision 30): this file must be **merged to `master`** before SCHEMA-V2 PR-A starts (GitHub PR status may stay `draft` until locked). **Implementation gate**: `Status: locked` before SCHEMA-V2 PR-D merges. PR-D runs after PR-A, PR-B, and PR-C are in `master`. - Re-index is already required by SCHEMA-V2 (`ONTOLOGY_VERSION` 13 → 14); HINTS-V3 does not bump it again. - Goes away: `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (deleted). Stays: every existing v1/v2 template (DESCRIBE rollups, FIND, RESOLVE, fuzzy-strategy hint). - Non-obvious constraint: hints v3 must never recommend a dot-key edge label as a `neighbors()` argument (carry-over from v2 propose §7.x). All template recommendations are checked against the canonical edge list. @@ -180,7 +180,7 @@ Each row references the SCHEMA-V2 use-case re-walk (§4 of that propose) where a | HV13 | Client subject, asks `HTTP_CALLS` outbound — resolver found no route | `Client{}` | `neighbors([client_id], 'out', ['HTTP_CALLS'])` returning `[]` | generic | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` only | | HV14 | Producer subject, asks `ASYNC_CALLS` outbound — unresolved broker | `Producer{}` | `neighbors([producer_id], 'out', ['ASYNC_CALLS'])` returning `[]` | n/a | `TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED` only | | HV15 | Method subject, asks both `HTTP_CALLS` and `DECLARES_CLIENT` outbound | `Symbol{symbol_kind=method}` | `neighbors([method_id], 'out', ['HTTP_CALLS', 'DECLARES_CLIENT'])` | one generic hint | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` for `HTTP_CALLS` only; no hint for `DECLARES_CLIENT` (HV9) | -| HV16 | Method subject, `ASYNC_CALLS` returns edges with fuzzy strategy | `Symbol{symbol_kind=method}` | non-empty `neighbors(..., ['ASYNC_CALLS'])` | v2 fuzzy hint | v2 fuzzy hint; `neighbors_empty_hints` not called | +| HV16 | Caller-side subject, `HTTP_CALLS` returns edges with fuzzy strategy (post-flip) | `Client{}` | non-empty `neighbors([client_id], 'out', ['HTTP_CALLS'])` | v2 fuzzy hint | v2 fuzzy hint; `neighbors_empty_hints` not called | | HV17 | Class-level subject, asks `EXPOSES` outbound | `Symbol{symbol_kind=class}` | `neighbors([class_id], 'out', ['EXPOSES'])` | generic | `TPL_NEIGHBORS_TYPE_LEVEL_REQUERY` — row 3 | | HV18 | Route subject, asks `DECLARES` outbound | `Route{}` | `neighbors([route_id], 'out', ['DECLARES'])` returning `[]` | generic | `TPL_NEIGHBORS_WRONG_SUBJECT_KIND` — Route matches neither Symbol endpoint; row 1 | | HV19 | CI: `EDGE_SCHEMA` coverage | n/a | n/a | n/a | For **each** edge `e` in `EDGE_SCHEMA`, ∃ a `(subject_node_label, direction)` pair such that `neighbors_empty_hints` would emit **at least one** of rows 1–3 or row 4 for a synthetic empty result. Does **not** require every empty query to hint. | @@ -244,7 +244,7 @@ Each row references the SCHEMA-V2 use-case re-walk (§4 of that propose) where a 12. **Implementation is SCHEMA-V2 PR-D**; this propose is a separate doc PR. 13. **`mcp_hints.py` imports `EDGE_SCHEMA` from `java_ontology`** — no copy. 14. **No back-compat alias** for `TPL_NEIGHBORS_EMPTY_KIND_CHECK`. -15. **Propose gate = draft PR before PR-A; lock before PR-D** — matches SCHEMA-V2 Decision 30. +15. **Propose gate = merged to `master` before PR-A; `Status: locked` before PR-D** — matches SCHEMA-V2 Decision 30 (draft PR on GitHub is the vehicle; the file must be on `master`). ## §8 — Risks and how we mitigate @@ -269,7 +269,8 @@ Each row references the SCHEMA-V2 use-case re-walk (§4 of that propose) where a | `TYPE_LEVEL_REQUERY` uses `{canonical_traversal}` only | Principle 1 — no `DECLARES` literal in `mcp_hints.py` | | `member_only` scoped to Symbol method-level edges only | Removed contradiction with post-flip HTTP/ASYNC Client/Producer endpoints | | §3.6 hint payload + multi-id rule | `neighbors_v2` does not pass subject/direction today | -| Propose gate: draft before PR-A, lock before PR-D | Was stricter than SCHEMA-V2 Decision 30 | +| Propose gate: merged before PR-A, locked before PR-D | Was stricter than SCHEMA-V2 Decision 30; TL;DR "draft PR" clarified | +| HV16 uses `Client` + post-flip `HTTP_CALLS` | Method `Symbol` cannot have non-empty `ASYNC_CALLS` post-flip | | Dropped `direction='any'` / HV18 replaced | API is `in` \| `out` only | | HV19 clarified as ∃ coverage per edge, not ∀ empty queries | Compatible with HV8/HV9/HV11 | | Principle 8 for brownfield empty hints | Explicit exception to structural-only framing |