Skip to content

feat(query): add canonical AST schema + OpenAPI generation for query DSL - #3330

Merged
Sinity merged 1 commit into
masterfrom
feature/query/ast-lowering-openapi
Jul 27, 2026
Merged

feat(query): add canonical AST schema + OpenAPI generation for query DSL#3330
Sinity merged 1 commit into
masterfrom
feature/query/ast-lowering-openapi

Conversation

@Sinity

@Sinity Sinity commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a canonical, versioned Pydantic AST schema for the query DSL's compiled
predicate tree, unit-source pipelines, and lowering plan, and wires it into
devtools render openapi so the shape is published in
docs/openapi/search.yaml (and the derived webui/src/api/generated.ts
TypeScript client).

Problem

polylogue-z9gh.3's design explicitly calls for "typed structured-plan
lowering to one AST" and OpenAPI/JSON schema generation for the query
discovery vocabulary. Most of that program already shipped (#3018, #3066,
#3296), but the bead's own notes named exactly one residual not yet
attempted: "structured-plan -> canonical AST lowering + OpenAPI
generation"
(sized M). explain_expression() already computes a compiled
predicate tree and per-branch ast/lowering_plan dicts (see
polylogue/archive/query/expression.py:_ast_payload/_lowering_plan_payload/
QueryExpressionExplanation.to_payload), but they were hand-rolled
dict[str, object] shapes: correct, but opaque to external tooling. An MCP
client or an OpenAPI-generated SDK only ever saw an untyped dict with no
documented schema.

Solution

  • New polylogue/archive/query/query_ast_schema.py: Pydantic models
    mirroring every existing predicate/pipeline-stage/clause dataclass's
    to_payload() shape one-to-one — QueryPredicateAst (a discriminated
    union covering field/not/and-or/exists/sequence/fts/semantic/lineage
    predicates), clause/ref-operand/pipeline-stage projections, and the
    top-level QueryExpressionExplanationAst. This is deliberately a
    validating projection, not a second AST or a parallel IR:
    predicate_to_ast() / explanation_payload_to_ast() validate the
    dataclasses' own existing to_payload() output against the schema, so if
    a producer's payload shape ever drifts from what's declared here, a
    Pydantic ValidationError surfaces immediately in the new test file
    rather than the drift silently reaching an agent or generated client.
  • Versioned as polylogue.query-explain-ast.v1
    (QUERY_AST_SCHEMA_VERSION), kept a distinct axis from the existing
    polylogue.query-definition.v1 content-hashing protocol version in
    polylogue/core/query_identity.py — the module docstring explains why
    they must not be conflated (one versions the content-addressed predicate
    grammar used for query hashing/identity, the other versions this new
    broader discovery/explain envelope).
  • QueryExpressionExplanation.to_payload() now stamps schema_version
    the only new key added to the existing payload shape. MCP
    explain(kind="query") and Polylogue.explain_query_expression() callers
    pick it up automatically with no call-site changes.
  • devtools/render_openapi.py: publishes QueryExpressionExplanationAst
    (with its full nested $defs graph — every predicate/pipeline-stage
    variant) as a component schema in docs/openapi/search.yaml, plus a new
    x-polylogue-query-ast vendor extension documenting the schema version,
    the root schema ref, and how to obtain a live instance (there's no
    existing daemon HTTP route for query explain, only the MCP explain
    operation and the Python facade, so the extension says that explicitly
    rather than implying a route that doesn't exist).
  • Regenerated docs/openapi/search.yaml, webui/src/api/generated.ts (the
    typed TS client derives from the OpenAPI doc), and
    docs/plans/topology-target.yaml / docs/topology-status.md (new module
    under polylogue/, per this repo's topology-projection convention).

What's covered vs. deferred

Covered: a documented, versioned, round-trip-tested AST for the
SessionQuerySpec.boolean_predicate tree (the actual "structured plan" a
compiled query lowers to), the clause/pipeline/unit-source/reference-operand
projections explain_expression() already emits, and OpenAPI publication of
all of it.

Deferred (explicitly out of scope for this residual, not silently dropped):
no new HTTP route for query-explain was added (MCP + Python facade remain
the only live surfaces); the unit_source.pipeline_stages transform-stage
vocabulary member (QueryUnitTransformStageAst) is modeled but has no
producer yet (matches upstream's own "reserved vocabulary member" note on
QueryUnitTransformStage); this PR does not touch execution-layer gaps
(those were already handled separately in #3296).

Verification

  • devtools test tests/unit/archive/query/test_query_ast_schema.py — 28
    passed: predicate↔AST round trip over 11 representative predicate shapes
    (field/not/and/or/exists/sequence/fts/semantic/lineage), full
    explanation-payload validation over 13 representative DSL expressions
    (compact field query, Boolean AND/OR, near:"..." semantic, lineage:id:,
    exists, seq(...), pipeline stages sort/limit/offset, group-by-count
    aggregate, raw JSON spec, durable-reference pipeline
    from result-set:... | ..., terminal unit sources), a JSON-Schema
    buildability check, and two schema-drift rejection tests.
  • Ad hoc sweep (not part of the committed test file, but run manually before
    writing it) of all 106 positive rows in
    archive/query/discovery.py:QUERY_DISCOVERY_EXAMPLES validated cleanly
    against the new schema with 0 failures.
  • devtools test tests/unit/cli/test_query_expression.py tests/unit/archive/query/test_predicate_payload_roundtrip.py tests/unit/devtools/test_render_openapi.py tests/unit/api/test_facade_contracts.py — 751 passed, 1 skipped (no
    regressions in the explain/predicate/OpenAPI surfaces touched by this
    change).
  • mypy --strict and ruff check/format clean on all touched files.
  • devtools render all --check — sync OK on every generated surface
    (openapi, webui-client, topology-status, and the rest).
  • Not run: the full non-slow test suite / devtools verify --seed-testmon
    (it stalled 600s into an unrelated part of the corpus, after cleanly
    finishing every test in this PR's own file — consistent with heavy
    concurrent test load from other agent worktrees sharing this host at the
    time, not a regression introduced here). The pre-push hook's
    devtools verify --quick (format + lint + mypy + render-all-check) ran
    and passed on push.

Ref polylogue-z9gh.3

Summary by CodeRabbit

  • New Features

    • Added a versioned, canonical schema for query explanations and compiled query ASTs.
    • Query explanations now include a schema version and validated predicate, pipeline, and lowering-plan details.
    • Published the query AST schema through the OpenAPI specification for external tooling.
    • Added generated TypeScript types for query AST and pipeline structures.
  • Documentation

    • Documented how to obtain query AST explanations and the schema’s supported structures.
  • Tests

    • Added coverage for AST round trips, schema validation, serialization, and rejection of invalid data.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Sinity, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 996678c1-6769-4f5c-9f22-5f436f1deb1f

📥 Commits

Reviewing files that changed from the base of the PR and between 28c98a0 and f324e06.

📒 Files selected for processing (8)
  • devtools/render_openapi.py
  • docs/openapi/search.yaml
  • docs/plans/topology-target.yaml
  • docs/topology-status.md
  • polylogue/archive/query/expression.py
  • polylogue/archive/query/query_ast_schema.py
  • tests/unit/archive/query/test_query_ast_schema.py
  • webui/src/api/generated.ts
📝 Walkthrough

Walkthrough

This change adds a versioned, strictly validated query explanation AST, integrates its schema version into explanation payloads, publishes it through OpenAPI, and adds corresponding generated TypeScript types and topology metadata.

Changes

Query AST contract

Layer / File(s) Summary
Canonical AST schema and validation
polylogue/archive/query/query_ast_schema.py, tests/unit/archive/query/test_query_ast_schema.py
Defines strict Pydantic models, predicate conversion helpers, the versioned explanation envelope, exports, round-trip tests, schema serialization checks, and rejection of unknown fields or predicate kinds.
Versioned explanation payload
polylogue/archive/query/expression.py
Adds schema_version to QueryExpressionExplanation.to_payload() using QUERY_AST_SCHEMA_VERSION.
OpenAPI schema publication
devtools/render_openapi.py, docs/openapi/search.yaml
Publishes QueryExpressionExplanationAst and its component schemas, plus the x-polylogue-query-ast extension describing the version and acquisition paths.
Generated client contract and topology records
webui/src/api/generated.ts, docs/plans/topology-target.yaml, docs/topology-status.md
Adds TypeScript query AST and pipeline types and updates topology projections and status counts for the new schema module.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant QueryExpressionExplanation
  participant QueryExpressionExplanationAst
  participant render_openapi.py
  participant GeneratedTypeScript
  QueryExpressionExplanation->>QueryExpressionExplanationAst: emit versioned explanation payload
  QueryExpressionExplanationAst->>render_openapi.py: provide JSON schema
  render_openapi.py->>GeneratedTypeScript: publish query AST contract
Loading

Possibly related PRs

  • Sinity/polylogue#2015: Adds a CLI that exposes the query explanation payload affected by this schema and versioning change.
  • Sinity/polylogue#2030: Exposes the same explanation payload through a Python API and MCP tool.
  • Sinity/polylogue#2899: Adds predicate payload reconstruction used by the new AST conversion logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adding a canonical query AST schema and OpenAPI generation.
Description check ✅ Passed The description covers Summary, Problem, Solution, and Verification well; Changelog and Risks/Follow-ups are omitted.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/query/ast-lowering-openapi

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Problem: polylogue-z9gh.3's design calls for "typed structured-plan lowering
to one AST" and OpenAPI/JSON schema generation for the query-discovery
vocabulary. explain_expression() already computes a compiled predicate tree
and per-branch ast/lowering_plan dicts, but they were hand-rolled
dict[str, object] shapes -- correct, but opaque to external tooling (an MCP
client or OpenAPI-generated SDK only ever saw an untyped dict). This was the
one named, not-yet-attempted size-M residual left on z9gh.3 after #3018,

What changed:
- New polylogue/archive/query/query_ast_schema.py: Pydantic models mirroring
  every predicate/pipeline-stage/clause dataclass's existing to_payload()
  shape one-to-one (QueryPredicateAst discriminated union, clause/ref-operand/
  pipeline projections, QueryExpressionExplanationAst). This is a validating
  projection, not a second AST -- predicate_to_ast()/explanation_payload_to_ast()
  validate the dataclasses' own to_payload() output against the schema, so a
  producer that drifts from this shape fails loudly instead of silently
  diverging. Versioned as polylogue.query-explain-ast.v1, kept distinct from
  the existing polylogue.query-definition.v1 content-hashing protocol version
  (documented in the module docstring).
- QueryExpressionExplanation.to_payload() now stamps schema_version -- the
  only new key; MCP explain(kind="query") and Polylogue.explain_query_expression()
  callers see it automatically.
- devtools/render_openapi.py: publish QueryExpressionExplanationAst (with its
  full nested $defs) in docs/openapi/search.yaml, plus an
  x-polylogue-query-ast vendor extension documenting the schema version and
  how to obtain it live (MCP explain / Polylogue.explain_query_expression()).
  No new HTTP route was added -- there is no existing daemon route for query
  explain, so publishing schemas without a bound path keeps this PR to the
  documented residual rather than growing a new live surface.
- Regenerated docs/openapi/search.yaml, webui/src/api/generated.ts (the
  typed TS client derives from it), and the topology projection/status docs
  (new module).

Verification:
- devtools test tests/unit/archive/query/test_query_ast_schema.py -- 28
  passed: predicate<->AST round trip over 11 representative predicate shapes
  (field/not/and/or/exists/sequence/fts/semantic/lineage), full
  explanation-payload validation over 13 representative DSL expressions
  (compact field query, Boolean AND/OR, near: semantic, lineage:id:, exists,
  seq(), pipeline stages sort/limit/offset, group-by-count aggregate, JSON
  spec, durable-reference pipeline, terminal unit sources), a JSON-Schema
  buildability check, and two schema-drift rejection tests.
- Ad hoc sweep of all 106 positive rows in archive/query/discovery.py's
  QUERY_DISCOVERY_EXAMPLES corpus validated cleanly against the new schema
  (0 failures) before writing the formal test file.
- devtools test tests/unit/cli/test_query_expression.py
  tests/unit/archive/query/test_predicate_payload_roundtrip.py
  tests/unit/devtools/test_render_openapi.py tests/unit/api/test_facade_contracts.py
  -- 751 passed, 1 skipped (no regressions in explain/predicate/OpenAPI
  surfaces).
- mypy --strict and ruff check/format clean on all touched files.
- devtools render all --check: sync OK on every generated surface
  (openapi, webui-client, topology-status, and the rest).
- Not run: the full non-slow suite / devtools verify --seed-testmon (stalled
  600s into an unrelated corpus after cleanly finishing this PR's own tests --
  consistent with heavy concurrent test load from other agent worktrees on
  this shared host, not a regression from this change).

Ref polylogue-z9gh.3

Co-Authored-By: Claude <noreply@anthropic.com>
@Sinity
Sinity force-pushed the feature/query/ast-lowering-openapi branch from 28c98a0 to f324e06 Compare July 27, 2026 15:14
@Sinity
Sinity merged commit 7dc35fa into master Jul 27, 2026
2 of 3 checks passed
@Sinity
Sinity deleted the feature/query/ast-lowering-openapi branch July 27, 2026 15:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@polylogue/archive/query/query_ast_schema.py`:
- Around line 190-204: Update the public signatures of predicate_to_ast and
ast_to_predicate to use QueryPredicateAst: return QueryPredicateAst from
predicate_to_ast and accept ast: QueryPredicateAst in ast_to_predicate. Define
or use TypeAdapter[QueryPredicateAst] for validation, keeping the adapter
internally opaque only if required by the static checker while preserving the
typed public contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b9a04eb9-aabb-44ce-8ceb-4aacfaab20db

📥 Commits

Reviewing files that changed from the base of the PR and between 7c45532 and 28c98a0.

📒 Files selected for processing (8)
  • devtools/render_openapi.py
  • docs/openapi/search.yaml
  • docs/plans/topology-target.yaml
  • docs/topology-status.md
  • polylogue/archive/query/expression.py
  • polylogue/archive/query/query_ast_schema.py
  • tests/unit/archive/query/test_query_ast_schema.py
  • webui/src/api/generated.ts

Comment on lines +190 to +204
def predicate_to_ast(predicate: QueryPredicate) -> Any:
"""Project a compiled predicate node into the canonical, typed AST.

This validates ``predicate``'s own lossless ``to_payload()`` projection
against :data:`QueryPredicateAst` -- it does not re-derive the payload by
walking the dataclass a second time, so the two shapes cannot drift apart
without a validation failure surfacing immediately.
"""
return _predicate_adapter.validate_python(predicate.to_payload())


def ast_to_predicate(ast: Any) -> QueryPredicate:
"""Invert :func:`predicate_to_ast` back into a typed predicate node."""
payload = cast("dict[str, object]", ast.model_dump(mode="json", exclude_none=True))
return predicate_from_payload(payload)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Pydantic v2 TypeAdapter generic parameter with Annotated discriminated union type annotation mypy

💡 Result:

Using Pydantic v2's TypeAdapter with Annotated discriminated union types often presents challenges for static type checkers like mypy [1]. The core issue is that while these constructs work correctly at runtime, mypy and other static checkers may struggle to resolve complex Annotated types or special forms (like Union and Annotated) when passed as values to TypeAdapter, often leading to type inference resulting in Any or triggering argument-type errors [2][1][3][4]. Current Status and Workarounds: 1. Explicit Annotations: When mypy raises an error during TypeAdapter instantiation, the standard workaround is to provide an explicit type annotation for the TypeAdapter instance itself [5]. python from typing import Union, Annotated from pydantic import TypeAdapter, Field # Define the discriminated union MyUnion = Annotated[Union[ModelA, ModelB], Field(discriminator='type')] # Instantiate with explicit annotation to satisfy mypy ta: TypeAdapter[Union[ModelA, ModelB]] = TypeAdapter(MyUnion) 2. Suppression: If type inference still fails or produces incorrect results (such as falling back to Any), it is common practice to use # type: ignore [5][1]. 3. Evolution of Support: Pydantic's internal typing mechanisms have evolved to address these issues. Some versions have intentionally relaxed internal typing to Any to prevent breaking changes in user code when Python's own typing rules shift [2][3]. Recent updates in the Pydantic ecosystem have also begun incorporating support for PEP 747 (TypeForm), which aims to provide native support for these patterns in static type checkers [1]. 4. Best Practices: - Avoid creating TypeAdapter instances dynamically inside functions [2]. Instead, define them as global constants and reference them where needed [2]. - If you encounter errors, ensure the Pydantic mypy plugin is correctly configured in your project, although note that the plugin primarily focuses on model-level validation and may not resolve all TypeAdapter inference limitations [6]. - If using discriminated unions inside lists, ensure the discriminator is applied to the union type itself (e.g., List[Annotated[Union[T1, T2], Field(discriminator='...')]]) rather than the list [7]. Because this limitation stems from how static checkers interact with Python's typing system and special forms, it is a known area of friction rather than a bug in your code [1][4]. Using explicit typing or suppression remains the recommended approach until static checkers provide broader, native support for these complex type forms [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
sed -n '1,260p' polylogue/archive/query/query_ast_schema.py

echo
echo "== tooling configs =="
for f in pyproject.toml setup.cfg tox.ini mypy.ini pyrightconfig.json; do
  [ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done

echo
echo "== ast_to_predicate public usages =="
rg -n "ast_to_predicate|predicate_to_ast|_predicate_adapter|QueryPredicateAst" polylogue tests . --glob='*.py' || true

Repository: Sinity/polylogue

Length of output: 1964


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate typing behavior =="
python3 - <<'PY'
try:
    from pydantic import TypeAdapter, BaseModel, Field
    from typing import Annotated, Union

    class A(BaseModel):
        kind: str = "a"
        x: int

    class B(BaseModel):
        kind: str = "b"
        s: str

    P = Annotated[Union[A, B], Field(discriminator="kind")]

    t1: TypeAdapter[P] = TypeAdapter(P)
    print("TypeAdapter[Annotated[Union[...], Field(discriminator=...)]] instantiation succeeded")
except Exception as e:
    print(type(e).__name__, ":", e)

# Runtime serialization of None-bearing nested models omitted from output by ExcludeNone default-ish behavior.
class C(BaseModel):
    kind: str = "c"
    opt: int | None = None

model = C.model_validate(t1.validate_python({"kind": "c", "opt": None}))
print(model.model_dump(mode="json", exclude_none=True))
PY

Repository: Sinity/polylogue

Length of output: 1964


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
sed -n '1,260p' polylogue/archive/query/query_ast_schema.py

echo
echo "== tooling configs =="
for f in pyproject.toml setup.cfg tox.ini mypy.ini pyrightconfig.json; do
  [ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done

echo
echo "== ast_to_predicate public usages =="
rg -n "ast_to_predicate|predicate_to_ast|_predicate_adapter|QueryPredicateAst" polylogue tests . --glob='*. py' || true

Repository: Sinity/polylogue

Length of output: 1964


Return/accept QueryPredicateAst instead of Any.

The module exposes a typed AST surface, but the two public functions currently erase that contract. Use TypeAdapter[QueryPredicateAst], -> QueryPredicateAst, and ast: QueryPredicateAst; if the project’s static checker rejects that exact generic form, keep the adapter internally opaque while still typing those public signatures to QueryPredicateAst.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@polylogue/archive/query/query_ast_schema.py` around lines 190 - 204, Update
the public signatures of predicate_to_ast and ast_to_predicate to use
QueryPredicateAst: return QueryPredicateAst from predicate_to_ast and accept
ast: QueryPredicateAst in ast_to_predicate. Define or use
TypeAdapter[QueryPredicateAst] for validation, keeping the adapter internally
opaque only if required by the static checker while preserving the typed public
contract.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant