diff --git a/devtools/render_openapi.py b/devtools/render_openapi.py index 64abb599f6..5077511567 100644 --- a/devtools/render_openapi.py +++ b/devtools/render_openapi.py @@ -32,6 +32,7 @@ from devtools.command_catalog import control_plane_command from devtools.render_support import write_if_changed from polylogue.archive.query.metadata import terminal_query_examples, terminal_query_source_list +from polylogue.archive.query.query_ast_schema import QUERY_AST_SCHEMA_VERSION, QueryExpressionExplanationAst from polylogue.archive.viewport import ( read_view_http_choices, read_view_http_format_choices, @@ -75,6 +76,7 @@ WebCredentialRevocationPayload, WebCredentialFailurePayload, QueryErrorPayload, + QueryExpressionExplanationAst, ) _WEB_CREDENTIAL_FAILURE_STATES = [ @@ -900,6 +902,23 @@ def _build_openapi_document() -> dict[str, Any]: }, }, "x-polylogue-route-contracts": [_route_contract_payload(contract) for contract in ROUTE_CONTRACTS], + "x-polylogue-query-ast": { + "schema_version": QUERY_AST_SCHEMA_VERSION, + "schema": "#/components/schemas/QueryExpressionExplanationAst", + "description": ( + "Canonical, versioned projection of the query DSL's compiled " + "predicate tree, unit-source pipeline, and lowering plan. " + "Every query DSL expression -- fielded compact queries, " + "``sessions where ...`` Boolean expressions, terminal unit " + "sources (``actions where ...``), and durable-reference " + "pipelines (``from result-set:... | ...``) -- lowers to this " + "one documented shape. Obtain it via the MCP ``explain`` " + 'operation (``kind="query"``) or ' + "``Polylogue.explain_query_expression(expression)``; there is " + "no separate HTTP route yet (tracked as a future extension of " + "this schema's live surface)." + ), + }, } diff --git a/docs/openapi/search.yaml b/docs/openapi/search.yaml index 9a0cf1e619..abca174c4c 100644 --- a/docs/openapi/search.yaml +++ b/docs/openapi/search.yaml @@ -3079,6 +3079,1144 @@ components: - error title: QueryErrorPayload type: object + QueryBoolPredicateAst: + additionalProperties: false + description: N-ary Boolean operator over predicate subtrees. + properties: + kind: + enum: + - and + - or + title: Kind + type: string + children: + items: + discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + title: Children + type: array + required: + - kind + title: QueryBoolPredicateAst + type: object + QueryExistsPredicateAst: + additionalProperties: false + description: Correlated structural predicate over a child archive unit. + properties: + kind: + const: exists + default: exists + title: Kind + type: string + unit: + enum: + - message + - action + - block + - assertion + - file + - run + - observed-event + - context-snapshot + - delegation + title: Unit + type: string + child: + discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + title: Child + required: + - unit + - child + title: QueryExistsPredicateAst + type: object + QueryExpressionAstNodeAst: + additionalProperties: false + description: 'Mirrors ``_ast_payload()``''s dict shape: one entry-tagged AST node.' + properties: + entry: + enum: + - json + - reference_pipeline + - unit_source + - boolean + - compact + title: Entry + type: string + clauses: + anyOf: + - items: + $ref: '#/components/schemas/QueryExpressionClauseAst' + type: array + - type: 'null' + default: null + title: Clauses + predicate: + anyOf: + - discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + - type: 'null' + default: null + title: Predicate + unit_source: + anyOf: + - $ref: '#/components/schemas/QueryUnitSourceAst' + - type: 'null' + default: null + reference_pipeline: + anyOf: + - $ref: '#/components/schemas/ReferenceQueryPipelineAst' + - type: 'null' + default: null + required: + - entry + title: QueryExpressionAstNodeAst + type: object + QueryExpressionClauseAst: + additionalProperties: false + description: Mirrors ``QueryExpressionExplainClause.to_payload()``. + properties: + kind: + enum: + - field + - count + - count_range + - date + - date_range + - text + - json + title: Kind + type: string + field: + anyOf: + - type: string + - type: 'null' + default: null + title: Field + value: + anyOf: + - type: string + - type: 'null' + default: null + title: Value + negated: + default: false + title: Negated + type: boolean + quoted: + default: false + title: Quoted + type: boolean + op: + anyOf: + - enum: + - '=' + - '>' + - '>=' + - < + - <= + type: string + - type: 'null' + default: null + title: Op + number: + anyOf: + - type: integer + - type: 'null' + default: null + title: Number + min_number: + anyOf: + - type: integer + - type: 'null' + default: null + title: Min Number + max_number: + anyOf: + - type: integer + - type: 'null' + default: null + title: Max Number + min_value: + anyOf: + - type: string + - type: 'null' + default: null + title: Min Value + max_value: + anyOf: + - type: string + - type: 'null' + default: null + title: Max Value + required: + - kind + title: QueryExpressionClauseAst + type: object + QueryFieldPredicateAst: + additionalProperties: false + description: Leaf predicate over one supported session-query field. + properties: + kind: + const: field + default: field + title: Kind + type: string + field: + title: Field + type: string + op: + default: '=' + enum: + - '=' + - '>' + - '>=' + - < + - <= + title: Op + type: string + values: + items: + type: string + title: Values + type: array + field_ref: + anyOf: + - $ref: '#/components/schemas/QueryFieldRefAst' + - type: 'null' + default: null + required: + - field + title: QueryFieldPredicateAst + type: object + QueryFieldRefAst: + additionalProperties: false + description: Validated field identity carried by a field-predicate leaf. + properties: + scope: + enum: + - session + - unit + title: Scope + type: string + name: + title: Name + type: string + source_name: + title: Source Name + type: string + unit: + anyOf: + - type: string + - type: 'null' + default: null + title: Unit + required: + - scope + - name + - source_name + title: QueryFieldRefAst + type: object + QueryLineagePredicateAst: + additionalProperties: false + description: Session-topology predicate selecting the seed's logical lineage. + properties: + kind: + const: lineage + default: lineage + title: Kind + type: string + unit: + const: session + default: session + title: Unit + type: string + seed_session_id: + title: Seed Session Id + type: string + required: + - seed_session_id + title: QueryLineagePredicateAst + type: object + QueryLoweringPlanAst: + additionalProperties: false + description: Mirrors ``_lowering_plan_payload()``. + properties: + lowerer: + title: Lowerer + type: string + selected_units: + items: + type: string + title: Selected Units + type: array + execution_legs: + items: + type: string + title: Execution Legs + type: array + plan_description: + items: + type: string + title: Plan Description + type: array + compatibility_selector: + anyOf: + - type: string + - type: 'null' + default: null + title: Compatibility Selector + pipeline: + anyOf: + - $ref: '#/components/schemas/QueryUnitPipelineAst' + - type: 'null' + default: null + pipeline_stages: + anyOf: + - items: + discriminator: + mapping: + count: '#/components/schemas/QueryUnitCountStageAst' + group: '#/components/schemas/QueryUnitGroupStageAst' + limit: '#/components/schemas/QueryUnitLimitStageAst' + offset: '#/components/schemas/QueryUnitOffsetStageAst' + session_scope: '#/components/schemas/QueryUnitSessionScopeStageAst' + sort: '#/components/schemas/QueryUnitSortStageAst' + terminal: '#/components/schemas/QueryUnitTerminalStageAst' + transform: '#/components/schemas/QueryUnitTransformStageAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryUnitSessionScopeStageAst' + - $ref: '#/components/schemas/QueryUnitSortStageAst' + - $ref: '#/components/schemas/QueryUnitLimitStageAst' + - $ref: '#/components/schemas/QueryUnitOffsetStageAst' + - $ref: '#/components/schemas/QueryUnitGroupStageAst' + - $ref: '#/components/schemas/QueryUnitCountStageAst' + - $ref: '#/components/schemas/QueryUnitTransformStageAst' + - $ref: '#/components/schemas/QueryUnitTerminalStageAst' + type: array + - type: 'null' + default: null + title: Pipeline Stages + reference_lineage: + anyOf: + - items: + type: string + type: array + - type: 'null' + default: null + title: Reference Lineage + required: + - lowerer + title: QueryLoweringPlanAst + type: object + QueryNotPredicateAst: + additionalProperties: false + description: Boolean negation over a predicate subtree. + properties: + kind: + const: not + default: not + title: Kind + type: string + child: + discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + title: Child + required: + - child + title: QueryNotPredicateAst + type: object + QuerySemanticPredicateAst: + additionalProperties: false + description: Semantic vector predicate over session message/block text. + properties: + kind: + const: semantic + default: semantic + title: Kind + type: string + unit: + const: session + default: session + title: Unit + type: string + text: + title: Text + type: string + required: + - text + title: QuerySemanticPredicateAst + type: object + QuerySequenceConstraintAst: + additionalProperties: false + description: Constraint on the edge between two action-sequence steps. + properties: + kind: + default: ordered + enum: + - ordered + - next + - within + title: Kind + type: string + within_ms: + anyOf: + - type: integer + - type: 'null' + default: null + title: Within Ms + title: QuerySequenceConstraintAst + type: object + QuerySequencePredicateAst: + additionalProperties: false + description: Ordered action-sequence predicate over a session. + properties: + kind: + const: sequence + default: sequence + title: Kind + type: string + unit: + const: action + default: action + title: Unit + type: string + steps: + items: + discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + title: Steps + type: array + constraints: + items: + $ref: '#/components/schemas/QuerySequenceConstraintAst' + title: Constraints + type: array + actions: + items: + type: string + title: Actions + type: array + title: QuerySequencePredicateAst + type: object + QueryTextPredicateAst: + additionalProperties: false + description: Lexical FTS predicate over session message/block text. + properties: + kind: + const: fts + default: fts + title: Kind + type: string + unit: + const: session + default: session + title: Unit + type: string + text: + title: Text + type: string + required: + - text + title: QueryTextPredicateAst + type: object + QueryUnitCountStageAst: + additionalProperties: false + properties: + kind: + const: count + default: count + title: Kind + type: string + metric: + const: count + default: count + title: Metric + type: string + title: QueryUnitCountStageAst + type: object + QueryUnitGroupStageAst: + additionalProperties: false + properties: + kind: + const: group + default: group + title: Kind + type: string + field: + anyOf: + - type: string + - type: 'null' + default: null + title: Field + fields: + anyOf: + - items: + type: string + type: array + - type: 'null' + default: null + title: Fields + title: QueryUnitGroupStageAst + type: object + QueryUnitLimitStageAst: + additionalProperties: false + properties: + kind: + const: limit + default: limit + title: Kind + type: string + value: + title: Value + type: integer + required: + - value + title: QueryUnitLimitStageAst + type: object + QueryUnitOffsetStageAst: + additionalProperties: false + properties: + kind: + const: offset + default: offset + title: Kind + type: string + value: + title: Value + type: integer + required: + - value + title: QueryUnitOffsetStageAst + type: object + QueryUnitPipelineAst: + additionalProperties: false + description: Mirrors ``QueryUnitPipeline.to_payload()``. + properties: + source: + $ref: '#/components/schemas/QueryUnitPipelineSourceAst' + stages: + items: + discriminator: + mapping: + count: '#/components/schemas/QueryUnitCountStageAst' + group: '#/components/schemas/QueryUnitGroupStageAst' + limit: '#/components/schemas/QueryUnitLimitStageAst' + offset: '#/components/schemas/QueryUnitOffsetStageAst' + session_scope: '#/components/schemas/QueryUnitSessionScopeStageAst' + sort: '#/components/schemas/QueryUnitSortStageAst' + terminal: '#/components/schemas/QueryUnitTerminalStageAst' + transform: '#/components/schemas/QueryUnitTransformStageAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryUnitSessionScopeStageAst' + - $ref: '#/components/schemas/QueryUnitSortStageAst' + - $ref: '#/components/schemas/QueryUnitLimitStageAst' + - $ref: '#/components/schemas/QueryUnitOffsetStageAst' + - $ref: '#/components/schemas/QueryUnitGroupStageAst' + - $ref: '#/components/schemas/QueryUnitCountStageAst' + - $ref: '#/components/schemas/QueryUnitTransformStageAst' + - $ref: '#/components/schemas/QueryUnitTerminalStageAst' + title: Stages + type: array + session_scope: + anyOf: + - discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + - type: 'null' + default: null + title: Session Scope + result: + anyOf: + - $ref: '#/components/schemas/QueryUnitPipelineResultAst' + - type: 'null' + default: null + required: + - source + title: QueryUnitPipelineAst + type: object + QueryUnitPipelineResultAst: + additionalProperties: false + properties: + sort: + anyOf: + - $ref: '#/components/schemas/QueryUnitSortSpecAst' + - type: 'null' + default: null + group_by: + anyOf: + - type: string + - type: 'null' + default: null + title: Group By + aggregate: + anyOf: + - const: count + type: string + - type: 'null' + default: null + title: Aggregate + limit: + anyOf: + - type: integer + - type: 'null' + default: null + title: Limit + offset: + anyOf: + - type: integer + - type: 'null' + default: null + title: Offset + title: QueryUnitPipelineResultAst + type: object + QueryUnitPipelineSourceAst: + additionalProperties: false + properties: + unit: + enum: + - message + - action + - block + - assertion + - file + - run + - observed-event + - context-snapshot + - delegation + title: Unit + type: string + predicate: + discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + title: Predicate + required: + - unit + - predicate + title: QueryUnitPipelineSourceAst + type: object + QueryUnitSessionScopeStageAst: + additionalProperties: false + properties: + kind: + const: session_scope + default: session_scope + title: Kind + type: string + predicate: + discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + title: Predicate + required: + - predicate + title: QueryUnitSessionScopeStageAst + type: object + QueryUnitSortSpecAst: + additionalProperties: false + description: Mirrors ``QueryUnitSort`` (field/direction). + properties: + field: + enum: + - time + - count + - key + title: Field + type: string + direction: + default: asc + enum: + - asc + - desc + title: Direction + type: string + required: + - field + title: QueryUnitSortSpecAst + type: object + QueryUnitSortStageAst: + additionalProperties: false + properties: + kind: + const: sort + default: sort + title: Kind + type: string + sort: + $ref: '#/components/schemas/QueryUnitSortSpecAst' + required: + - sort + title: QueryUnitSortStageAst + type: object + QueryUnitSourceAst: + additionalProperties: false + description: Mirrors the ``unit_source`` branch of ``_ast_payload()``. + properties: + unit: + enum: + - message + - action + - block + - assertion + - file + - run + - observed-event + - context-snapshot + - delegation + title: Unit + type: string + predicate: + discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + title: Predicate + session_predicate: + anyOf: + - discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + - type: 'null' + default: null + title: Session Predicate + limit: + anyOf: + - type: integer + - type: 'null' + default: null + title: Limit + offset: + anyOf: + - type: integer + - type: 'null' + default: null + title: Offset + sort: + anyOf: + - $ref: '#/components/schemas/QueryUnitSortSpecAst' + - type: 'null' + default: null + group_by: + anyOf: + - type: string + - type: 'null' + default: null + title: Group By + aggregate: + anyOf: + - const: count + type: string + - type: 'null' + default: null + title: Aggregate + pipeline_stages: + items: + discriminator: + mapping: + count: '#/components/schemas/QueryUnitCountStageAst' + group: '#/components/schemas/QueryUnitGroupStageAst' + limit: '#/components/schemas/QueryUnitLimitStageAst' + offset: '#/components/schemas/QueryUnitOffsetStageAst' + session_scope: '#/components/schemas/QueryUnitSessionScopeStageAst' + sort: '#/components/schemas/QueryUnitSortStageAst' + terminal: '#/components/schemas/QueryUnitTerminalStageAst' + transform: '#/components/schemas/QueryUnitTransformStageAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryUnitSessionScopeStageAst' + - $ref: '#/components/schemas/QueryUnitSortStageAst' + - $ref: '#/components/schemas/QueryUnitLimitStageAst' + - $ref: '#/components/schemas/QueryUnitOffsetStageAst' + - $ref: '#/components/schemas/QueryUnitGroupStageAst' + - $ref: '#/components/schemas/QueryUnitCountStageAst' + - $ref: '#/components/schemas/QueryUnitTransformStageAst' + - $ref: '#/components/schemas/QueryUnitTerminalStageAst' + title: Pipeline Stages + type: array + pipeline: + $ref: '#/components/schemas/QueryUnitPipelineAst' + required: + - unit + - predicate + - pipeline + title: QueryUnitSourceAst + type: object + QueryUnitTerminalStageAst: + additionalProperties: false + properties: + kind: + const: terminal + default: terminal + title: Kind + type: string + action: + title: Action + type: string + args: + anyOf: + - additionalProperties: + type: string + type: object + - type: 'null' + default: null + title: Args + required: + - action + title: QueryUnitTerminalStageAst + type: object + QueryUnitTransformStageAst: + additionalProperties: false + properties: + kind: + const: transform + default: transform + title: Kind + type: string + name: + title: Name + type: string + args: + anyOf: + - additionalProperties: + type: string + type: object + - type: 'null' + default: null + title: Args + required: + - name + title: QueryUnitTransformStageAst + type: object + RefOperandAst: + additionalProperties: false + description: Mirrors ``RefOperand.to_payload()``. + properties: + kind: + const: ref_operand + default: ref_operand + title: Kind + type: string + reference: + title: Reference + type: string + reference_kind: + title: Reference Kind + type: string + evaluation_mode: + enum: + - re-evaluate + - retained + - resolver-defined + title: Evaluation Mode + type: string + grain: + anyOf: + - type: string + - type: 'null' + default: null + title: Grain + required: + - reference + - reference_kind + - evaluation_mode + title: RefOperandAst + type: object + ReferenceQueryPipelineAst: + additionalProperties: false + description: Mirrors ``ReferenceQueryPipeline.to_payload()``. + properties: + source: + $ref: '#/components/schemas/RefOperandAst' + stages: + items: + type: string + title: Stages + type: array + required: + - source + title: ReferenceQueryPipelineAst + type: object + QueryExpressionExplanationAst: + additionalProperties: false + description: 'Canonical, versioned projection of ``QueryExpressionExplanation.to_payload()``. + + + This is the schema published in ``docs/openapi/search.yaml`` and the + + documented shape of the MCP ``explain`` operation''s ``kind="query"`` + + result (``polylogue.mcp.server_cutover:explain`` -> + + ``Polylogue.explain_query_expression``). It is deliberately additive over + + history: every field already existed in the hand-rolled payload dict this + + module validates against; ``schema_version`` is the only new key.' + properties: + schema_version: + const: polylogue.query-explain-ast.v1 + default: polylogue.query-explain-ast.v1 + title: Schema Version + type: string + source_text: + title: Source Text + type: string + clauses: + items: + $ref: '#/components/schemas/QueryExpressionClauseAst' + title: Clauses + type: array + predicate: + anyOf: + - discriminator: + mapping: + and: '#/components/schemas/QueryBoolPredicateAst' + exists: '#/components/schemas/QueryExistsPredicateAst' + field: '#/components/schemas/QueryFieldPredicateAst' + fts: '#/components/schemas/QueryTextPredicateAst' + lineage: '#/components/schemas/QueryLineagePredicateAst' + not: '#/components/schemas/QueryNotPredicateAst' + or: '#/components/schemas/QueryBoolPredicateAst' + semantic: '#/components/schemas/QuerySemanticPredicateAst' + sequence: '#/components/schemas/QuerySequencePredicateAst' + propertyName: kind + oneOf: + - $ref: '#/components/schemas/QueryFieldPredicateAst' + - $ref: '#/components/schemas/QueryNotPredicateAst' + - $ref: '#/components/schemas/QueryBoolPredicateAst' + - $ref: '#/components/schemas/QueryExistsPredicateAst' + - $ref: '#/components/schemas/QuerySequencePredicateAst' + - $ref: '#/components/schemas/QueryTextPredicateAst' + - $ref: '#/components/schemas/QuerySemanticPredicateAst' + - $ref: '#/components/schemas/QueryLineagePredicateAst' + - type: 'null' + default: null + title: Predicate + ast: + anyOf: + - $ref: '#/components/schemas/QueryExpressionAstNodeAst' + - type: 'null' + default: null + lowerer: + title: Lowerer + type: string + lowering_plan: + anyOf: + - $ref: '#/components/schemas/QueryLoweringPlanAst' + - type: 'null' + default: null + selected_units: + items: + type: string + title: Selected Units + type: array + execution_legs: + items: + type: string + title: Execution Legs + type: array + plan_description: + items: + type: string + title: Plan Description + type: array + unsupported_nodes: + items: + type: string + title: Unsupported Nodes + type: array + required: + - source_text + - lowerer + title: QueryExpressionExplanationAst + type: object securitySchemes: machineBearer: type: http @@ -3663,3 +4801,11 @@ x-polylogue-route-contracts: stability: stable auth_policy: credential_and_same_origin response_contract: mutation envelope +x-polylogue-query-ast: + schema_version: polylogue.query-explain-ast.v1 + schema: '#/components/schemas/QueryExpressionExplanationAst' + description: Canonical, versioned projection of the query DSL's compiled predicate tree, unit-source pipeline, and lowering + plan. Every query DSL expression -- fielded compact queries, ``sessions where ...`` Boolean expressions, terminal unit + sources (``actions where ...``), and durable-reference pipelines (``from result-set:... | ...``) -- lowers to this one + documented shape. Obtain it via the MCP ``explain`` operation (``kind="query"``) or ``Polylogue.explain_query_expression(expression)``; + there is no separate HTTP route yet (tracked as a future extension of this schema's live surface). diff --git a/docs/plans/topology-target.yaml b/docs/plans/topology-target.yaml index ae627be5d4..78e2ddb210 100644 --- a/docs/plans/topology-target.yaml +++ b/docs/plans/topology-target.yaml @@ -356,7 +356,7 @@ files: owner: archive-query reason: archive-domain query semantics - path: polylogue/archive/query/expression.py - loc: 3480 + loc: 3489 target: polylogue/archive/query/expression.py owner: archive-query reason: archive-domain query semantics @@ -420,6 +420,11 @@ files: target: polylogue/archive/query/production_evaluator.py owner: archive-query reason: archive-domain query semantics + - path: polylogue/archive/query/query_ast_schema.py + loc: 443 + target: polylogue/archive/query/query_ast_schema.py + owner: archive-query + reason: archive-domain query semantics - path: polylogue/archive/query/retrieval.py loc: 49 target: polylogue/archive/query/retrieval.py @@ -770,7 +775,7 @@ files: target: polylogue/browser_capture/__init__.py owner: stable - path: polylogue/browser_capture/actions.py - loc: 578 + loc: 645 target: polylogue/browser_capture/actions.py owner: stable - path: polylogue/browser_capture/capture_jobs.py @@ -782,7 +787,7 @@ files: target: polylogue/browser_capture/identity.py owner: stable - path: polylogue/browser_capture/models.py - loc: 654 + loc: 694 target: polylogue/browser_capture/models.py owner: stable - path: polylogue/browser_capture/pairing.py @@ -794,11 +799,11 @@ files: target: polylogue/browser_capture/receiver.py owner: stable - path: polylogue/browser_capture/route_contracts.py - loc: 329 + loc: 340 target: polylogue/browser_capture/route_contracts.py owner: stable - path: polylogue/browser_capture/server.py - loc: 845 + loc: 876 target: polylogue/browser_capture/server.py owner: stable - path: polylogue/cli/__init__.py @@ -1006,7 +1011,7 @@ files: target: polylogue/cli/commands/paths.py owner: stable - path: polylogue/cli/commands/reconcile_work_effects.py - loc: 140 + loc: 157 target: polylogue/cli/commands/reconcile_work_effects.py owner: stable - path: polylogue/cli/commands/reset.py @@ -2084,7 +2089,7 @@ files: target: polylogue/insights/transforms.py owner: stable - path: polylogue/insights/work_effects.py - loc: 439 + loc: 581 target: polylogue/insights/work_effects.py owner: stable - path: polylogue/insights/work_evidence.py @@ -3798,7 +3803,7 @@ files: target: TBD owner: storage-domain - path: polylogue/storage/raw_reconciler.py - loc: 1451 + loc: 1457 target: TBD owner: storage-domain - path: polylogue/storage/raw_retention.py @@ -3807,7 +3812,7 @@ files: owner: storage-root reason: storage-root cross-cutting helper - path: polylogue/storage/repair.py - loc: 6872 + loc: 6897 target: polylogue/storage/repair.py owner: storage-root reason: storage-root cross-cutting helper diff --git a/docs/topology-status.md b/docs/topology-status.md index 9b1652c808..343afc143b 100644 --- a/docs/topology-status.md +++ b/docs/topology-status.md @@ -19,7 +19,7 @@ Generated by `devtools render topology-status`. Reads `docs/plans/topology-targe | archive-phase | — | 2 | 2 | 0 | 0 | | archive-projection | — | 5 | 5 | 0 | 0 | | archive-provider | — | 2 | 2 | 0 | 0 | -| archive-query | archive query semantics | 35 | 35 | 0 | 0 | +| archive-query | archive query semantics | 36 | 36 | 0 | 0 | | archive-raw-payload | — | 5 | 5 | 0 | 0 | | archive-semantic | — | 12 | 12 | 0 | 0 | | archive-session | — | 18 | 18 | 0 | 0 | @@ -32,8 +32,8 @@ Generated by `devtools render topology-status`. Reads `docs/plans/topology-targe - **Kernel** (polylogue/ root): 8 - **Primitives** (storage-root): 19 - **TBD** (cell needs explicit assignment): 10 -- **Total declared**: 1082 -- **Realized polylogue/**/*.py**: 1082 files declared +- **Total declared**: 1083 +- **Realized polylogue/**/*.py**: 1083 files declared ### TBD cells (require explicit routing) diff --git a/polylogue/archive/query/expression.py b/polylogue/archive/query/expression.py index 62e208652f..684d239ef5 100644 --- a/polylogue/archive/query/expression.py +++ b/polylogue/archive/query/expression.py @@ -733,7 +733,16 @@ class QueryExpressionExplanation: lowering_plan: dict[str, object] | None = None def to_payload(self) -> dict[str, object]: + # Local import: query_ast_schema.py depends on expression.py's own + # QueryUnitName (via metadata.py, not expression.py directly), so a + # module-level import here would still be safe, but this stays local + # to keep the one-way dependency (schema module never imports this + # module) obvious at a glance -- see query_ast_schema's module + # docstring for the versioning rationale. + from polylogue.archive.query.query_ast_schema import QUERY_AST_SCHEMA_VERSION + return { + "schema_version": QUERY_AST_SCHEMA_VERSION, "source_text": self.source_text, "clauses": [clause.to_payload() for clause in self.clauses], "predicate": self.predicate.to_payload() if self.predicate is not None else None, diff --git a/polylogue/archive/query/query_ast_schema.py b/polylogue/archive/query/query_ast_schema.py new file mode 100644 index 0000000000..f34163b27d --- /dev/null +++ b/polylogue/archive/query/query_ast_schema.py @@ -0,0 +1,443 @@ +"""Canonical, versioned Pydantic projection of the query DSL's compiled AST. + +``polylogue/archive/query/expression.py:explain_expression`` already computes +a compiled predicate tree, a per-branch ``ast`` dict, and a ``lowering_plan`` +dict for every query DSL expression (fielded compact queries, ``sessions +where ...`` Boolean expressions, terminal unit sources such as ``actions +where ...``, and durable-reference pipelines). Those dicts are produced by +hand-rolled ``to_payload()`` methods scattered across +:mod:`polylogue.archive.query.predicate` and +:mod:`polylogue.archive.query.expression` -- correct, but untyped and +undocumented from an external consumer's point of view (an MCP client or +OpenAPI-generated SDK sees ``dict[str, object] | None``). + +This module does **not** introduce a second AST or a parallel intermediate +representation. It defines Pydantic models that mirror the existing +dataclasses' ``to_payload()`` shapes one-to-one, then *validates* the +already-produced payload against them (see :func:`explanation_payload_to_ast`, +:func:`predicate_to_ast`). If a producer's ``to_payload()`` ever drifts from +the shape declared here, validation fails loudly in +``tests/unit/archive/query/test_query_ast_schema.py`` rather than silently +diverging -- that test is the parity gate the bead's design calls for +("adding/removing a field ... fails one actionable check"). + +Two independent version axes are in play and must not be conflated: + +* :data:`polylogue.core.query_identity.QUERY_DEFINITION_PROTOCOL_VERSION` + (``polylogue.query-definition.v1``) versions the *content-addressed* + predicate grammar used for query hashing/identity + (``predicate_from_payload`` / ``QueryPredicate.to_payload``). This module's + :data:`QueryPredicateAst` union is a typed, schema-validated *view* of that + same v1 grammar -- it does not define a new grammar version. +* :data:`QUERY_AST_SCHEMA_VERSION` (``polylogue.query-explain-ast.v1``) + versions the broader *discovery/explain* envelope defined in this module + (clauses, unit sources, pipelines, lowering plan) that has no prior typed + home. Bump it when this envelope's shape changes non-additively. + +Reused, not reinvented: every leaf/composite predicate kind, pipeline stage +kind, and terminal action here matches an existing closed vocabulary in +:mod:`polylogue.archive.query.predicate` and +:mod:`polylogue.archive.query.expression`. This module adds a stable, +JSON-Schema-capable serialization shape on top of them for external tooling +(OpenAPI generation, MCP-facing typed discovery) -- see +``devtools/render_openapi.py`` and +``polylogue/archive/query/expression.py:QueryExpressionExplanation.to_payload``. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal, cast + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from polylogue.archive.query.metadata import QueryUnitName +from polylogue.archive.query.predicate import ( + QueryBoolOp, + QueryCompareOp, + QueryExistsUnit, + QueryPredicate, + QuerySequenceConstraintKind, + predicate_from_payload, +) + +#: Version stamp for the canonical query-explain AST envelope defined below +#: (:class:`QueryExpressionExplanationAst` and everything it nests). See the +#: module docstring for how this relates to +#: ``QUERY_DEFINITION_PROTOCOL_VERSION``. +QUERY_AST_SCHEMA_VERSION: Literal["polylogue.query-explain-ast.v1"] = "polylogue.query-explain-ast.v1" + + +class _AstModel(BaseModel): + """Shared strict base: unknown keys fail validation instead of being dropped.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +# --------------------------------------------------------------------------- +# Predicate tree -- mirrors polylogue.archive.query.predicate.QueryPredicate +# --------------------------------------------------------------------------- + + +class QueryFieldRefAst(_AstModel): + """Validated field identity carried by a field-predicate leaf.""" + + scope: Literal["session", "unit"] + name: str + source_name: str + unit: str | None = None + + +class QueryFieldPredicateAst(_AstModel): + """Leaf predicate over one supported session-query field.""" + + kind: Literal["field"] = "field" + field: str + op: QueryCompareOp = "=" + values: list[str] = Field(default_factory=list) + field_ref: QueryFieldRefAst | None = None + + +class QueryNotPredicateAst(_AstModel): + """Boolean negation over a predicate subtree.""" + + kind: Literal["not"] = "not" + child: QueryPredicateAst + + +class QueryBoolPredicateAst(_AstModel): + """N-ary Boolean operator over predicate subtrees.""" + + kind: QueryBoolOp + children: list[QueryPredicateAst] = Field(default_factory=list) + + +class QuerySequenceConstraintAst(_AstModel): + """Constraint on the edge between two action-sequence steps.""" + + kind: QuerySequenceConstraintKind = "ordered" + within_ms: int | None = None + + +class QueryExistsPredicateAst(_AstModel): + """Correlated structural predicate over a child archive unit.""" + + kind: Literal["exists"] = "exists" + unit: QueryExistsUnit + child: QueryPredicateAst + + +class QuerySequencePredicateAst(_AstModel): + """Ordered action-sequence predicate over a session.""" + + kind: Literal["sequence"] = "sequence" + unit: Literal["action"] = "action" + steps: list[QueryPredicateAst] = Field(default_factory=list) + constraints: list[QuerySequenceConstraintAst] = Field(default_factory=list) + actions: list[str] = Field(default_factory=list) + + +class QueryTextPredicateAst(_AstModel): + """Lexical FTS predicate over session message/block text.""" + + kind: Literal["fts"] = "fts" + unit: Literal["session"] = "session" + text: str + + +class QuerySemanticPredicateAst(_AstModel): + """Semantic vector predicate over session message/block text.""" + + kind: Literal["semantic"] = "semantic" + unit: Literal["session"] = "session" + text: str + + +class QueryLineagePredicateAst(_AstModel): + """Session-topology predicate selecting the seed's logical lineage.""" + + kind: Literal["lineage"] = "lineage" + unit: Literal["session"] = "session" + seed_session_id: str + + +QueryPredicateAst = Annotated[ + QueryFieldPredicateAst + | QueryNotPredicateAst + | QueryBoolPredicateAst + | QueryExistsPredicateAst + | QuerySequencePredicateAst + | QueryTextPredicateAst + | QuerySemanticPredicateAst + | QueryLineagePredicateAst, + Field(discriminator="kind"), +] + +for _predicate_model in ( + QueryFieldPredicateAst, + QueryNotPredicateAst, + QueryBoolPredicateAst, + QueryExistsPredicateAst, + QuerySequencePredicateAst, + QueryTextPredicateAst, + QuerySemanticPredicateAst, + QueryLineagePredicateAst, +): + _predicate_model.model_rebuild() + +_predicate_adapter: TypeAdapter[Any] = TypeAdapter(QueryPredicateAst) + + +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) + + +# --------------------------------------------------------------------------- +# Explain clause / reference-operand / pipeline projections +# --------------------------------------------------------------------------- + + +class QueryExpressionClauseAst(_AstModel): + """Mirrors ``QueryExpressionExplainClause.to_payload()``.""" + + kind: Literal["field", "count", "count_range", "date", "date_range", "text", "json"] + field: str | None = None + value: str | None = None + negated: bool = False + quoted: bool = False + op: QueryCompareOp | None = None + number: int | None = None + min_number: int | None = None + max_number: int | None = None + min_value: str | None = None + max_value: str | None = None + + +class RefOperandAst(_AstModel): + """Mirrors ``RefOperand.to_payload()``.""" + + kind: Literal["ref_operand"] = "ref_operand" + reference: str + reference_kind: str + evaluation_mode: Literal["re-evaluate", "retained", "resolver-defined"] + grain: str | None = None + + +class ReferenceQueryPipelineAst(_AstModel): + """Mirrors ``ReferenceQueryPipeline.to_payload()``.""" + + source: RefOperandAst + stages: list[str] = Field(default_factory=list) + + +class QueryUnitSortSpecAst(_AstModel): + """Mirrors ``QueryUnitSort`` (field/direction).""" + + field: Literal["time", "count", "key"] + direction: Literal["asc", "desc"] = "asc" + + +class QueryUnitSessionScopeStageAst(_AstModel): + kind: Literal["session_scope"] = "session_scope" + predicate: QueryPredicateAst + + +class QueryUnitSortStageAst(_AstModel): + kind: Literal["sort"] = "sort" + sort: QueryUnitSortSpecAst + + +class QueryUnitLimitStageAst(_AstModel): + kind: Literal["limit"] = "limit" + value: int + + +class QueryUnitOffsetStageAst(_AstModel): + kind: Literal["offset"] = "offset" + value: int + + +class QueryUnitGroupStageAst(_AstModel): + kind: Literal["group"] = "group" + field: str | None = None + fields: list[str] | None = None + + +class QueryUnitCountStageAst(_AstModel): + kind: Literal["count"] = "count" + metric: Literal["count"] = "count" + + +class QueryUnitTransformStageAst(_AstModel): + kind: Literal["transform"] = "transform" + name: str + args: dict[str, str] | None = None + + +class QueryUnitTerminalStageAst(_AstModel): + kind: Literal["terminal"] = "terminal" + action: str + args: dict[str, str] | None = None + + +QueryUnitPipelineStageAst = Annotated[ + QueryUnitSessionScopeStageAst + | QueryUnitSortStageAst + | QueryUnitLimitStageAst + | QueryUnitOffsetStageAst + | QueryUnitGroupStageAst + | QueryUnitCountStageAst + | QueryUnitTransformStageAst + | QueryUnitTerminalStageAst, + Field(discriminator="kind"), +] + +QueryUnitSessionScopeStageAst.model_rebuild() + + +class QueryUnitPipelineSourceAst(_AstModel): + unit: QueryUnitName + predicate: QueryPredicateAst + + +class QueryUnitPipelineResultAst(_AstModel): + sort: QueryUnitSortSpecAst | None = None + group_by: str | None = None + aggregate: Literal["count"] | None = None + limit: int | None = None + offset: int | None = None + + +class QueryUnitPipelineAst(_AstModel): + """Mirrors ``QueryUnitPipeline.to_payload()``.""" + + source: QueryUnitPipelineSourceAst + stages: list[QueryUnitPipelineStageAst] = Field(default_factory=list) + session_scope: QueryPredicateAst | None = None + result: QueryUnitPipelineResultAst | None = None + + +class QueryUnitSourceAst(_AstModel): + """Mirrors the ``unit_source`` branch of ``_ast_payload()``.""" + + unit: QueryUnitName + predicate: QueryPredicateAst + session_predicate: QueryPredicateAst | None = None + limit: int | None = None + offset: int | None = None + sort: QueryUnitSortSpecAst | None = None + group_by: str | None = None + aggregate: Literal["count"] | None = None + pipeline_stages: list[QueryUnitPipelineStageAst] = Field(default_factory=list) + pipeline: QueryUnitPipelineAst + + +class QueryExpressionAstNodeAst(_AstModel): + """Mirrors ``_ast_payload()``'s dict shape: one entry-tagged AST node.""" + + entry: Literal["json", "reference_pipeline", "unit_source", "boolean", "compact"] + clauses: list[QueryExpressionClauseAst] | None = None + predicate: QueryPredicateAst | None = None + unit_source: QueryUnitSourceAst | None = None + reference_pipeline: ReferenceQueryPipelineAst | None = None + + +class QueryLoweringPlanAst(_AstModel): + """Mirrors ``_lowering_plan_payload()``.""" + + lowerer: str + selected_units: list[str] = Field(default_factory=list) + execution_legs: list[str] = Field(default_factory=list) + plan_description: list[str] = Field(default_factory=list) + compatibility_selector: str | None = None + pipeline: QueryUnitPipelineAst | None = None + pipeline_stages: list[QueryUnitPipelineStageAst] | None = None + #: Durable-reference ancestry (formatted ``ObjectRef`` strings), present + #: only on the ``reference-operand-to-planner-relation`` lowerer branch + #: (``explain_expression``'s ``reference_pipeline`` entry). + reference_lineage: list[str] | None = None + + +class QueryExpressionExplanationAst(_AstModel): + """Canonical, versioned projection of ``QueryExpressionExplanation.to_payload()``. + + This is the schema published in ``docs/openapi/search.yaml`` and the + documented shape of the MCP ``explain`` operation's ``kind="query"`` + result (``polylogue.mcp.server_cutover:explain`` -> + ``Polylogue.explain_query_expression``). It is deliberately additive over + history: every field already existed in the hand-rolled payload dict this + module validates against; ``schema_version`` is the only new key. + """ + + schema_version: Literal["polylogue.query-explain-ast.v1"] = QUERY_AST_SCHEMA_VERSION + source_text: str + clauses: list[QueryExpressionClauseAst] = Field(default_factory=list) + predicate: QueryPredicateAst | None = None + ast: QueryExpressionAstNodeAst | None = None + lowerer: str + lowering_plan: QueryLoweringPlanAst | None = None + selected_units: list[str] = Field(default_factory=list) + execution_legs: list[str] = Field(default_factory=list) + plan_description: list[str] = Field(default_factory=list) + unsupported_nodes: list[str] = Field(default_factory=list) + + +def explanation_payload_to_ast(payload: dict[str, object]) -> QueryExpressionExplanationAst: + """Validate an already-built ``QueryExpressionExplanation.to_payload()`` dict. + + This is the parity gate: any hand-rolled payload shape that does not + match this module's declared schema raises a Pydantic ``ValidationError`` + here rather than silently reaching an agent or generated client. + """ + return QueryExpressionExplanationAst.model_validate(payload) + + +__all__ = [ + "QUERY_AST_SCHEMA_VERSION", + "QueryBoolPredicateAst", + "QueryExistsPredicateAst", + "QueryExpressionAstNodeAst", + "QueryExpressionClauseAst", + "QueryExpressionExplanationAst", + "QueryFieldPredicateAst", + "QueryFieldRefAst", + "QueryLineagePredicateAst", + "QueryLoweringPlanAst", + "QueryNotPredicateAst", + "QueryPredicateAst", + "QuerySemanticPredicateAst", + "QuerySequenceConstraintAst", + "QuerySequencePredicateAst", + "QueryTextPredicateAst", + "QueryUnitCountStageAst", + "QueryUnitGroupStageAst", + "QueryUnitLimitStageAst", + "QueryUnitOffsetStageAst", + "QueryUnitPipelineAst", + "QueryUnitPipelineResultAst", + "QueryUnitPipelineSourceAst", + "QueryUnitPipelineStageAst", + "QueryUnitSessionScopeStageAst", + "QueryUnitSortSpecAst", + "QueryUnitSortStageAst", + "QueryUnitSourceAst", + "QueryUnitTerminalStageAst", + "QueryUnitTransformStageAst", + "RefOperandAst", + "ReferenceQueryPipelineAst", + "ast_to_predicate", + "explanation_payload_to_ast", + "predicate_to_ast", +] diff --git a/tests/unit/archive/query/test_query_ast_schema.py b/tests/unit/archive/query/test_query_ast_schema.py new file mode 100644 index 0000000000..da49ee849a --- /dev/null +++ b/tests/unit/archive/query/test_query_ast_schema.py @@ -0,0 +1,161 @@ +"""Round-trip and parity tests for the canonical query-AST Pydantic schema. + +``polylogue.archive.query.query_ast_schema`` is a *validating* Pydantic +projection over the existing hand-rolled ``to_payload()`` dicts produced by +:mod:`polylogue.archive.query.predicate` and +:mod:`polylogue.archive.query.expression`. These tests exercise two +independent claims: + +1. The predicate-tree AST round-trips losslessly: + ``predicate -> predicate_to_ast -> ast_to_predicate == predicate`` for a + representative spread of predicate shapes (field leaves, Boolean AND/OR + trees, exists/sequence structural predicates, and the three unit-scoped + leaves: fts/semantic/lineage). +2. The full ``explain_expression()`` payload for a representative spread of + DSL surfaces (compact field query, Boolean ``sessions where``, ``near:`` + semantic and ``near:id:`` lineage seeds, terminal unit sources with + pipeline stages, a durable-reference pipeline, and a raw JSON spec) is + valid against :class:`QueryExpressionExplanationAst` -- i.e. this schema + is not aspirational, it is what the parser+lowerer actually emit today. + +If a producer ever changes shape without a matching schema update, these +tests fail with a Pydantic ``ValidationError`` (parity gate), not a silent +divergence an agent or generated OpenAPI client would only discover later. +""" + +from __future__ import annotations + +import pytest +from pydantic import TypeAdapter, ValidationError + +from polylogue.archive.query.expression import explain_expression +from polylogue.archive.query.predicate import ( + QueryBoolPredicate, + QueryExistsPredicate, + QueryFieldPredicate, + QueryFieldRef, + QueryLineagePredicate, + QueryNotPredicate, + QueryPredicate, + QuerySemanticPredicate, + QuerySequenceConstraint, + QuerySequencePredicate, + QueryTextPredicate, +) +from polylogue.archive.query.query_ast_schema import ( + QUERY_AST_SCHEMA_VERSION, + QueryExpressionExplanationAst, + QueryPredicateAst, + ast_to_predicate, + explanation_payload_to_ast, + predicate_to_ast, +) + +_PREDICATE_ROUNDTRIP_CASES: tuple[QueryPredicate, ...] = ( + QueryFieldPredicate(field="origin", values=("codex-session",), op="="), + QueryFieldPredicate(field="origin", values=("codex-session",), op="=").with_field_ref( + QueryFieldRef(scope="session", name="origin", source_name="origin") + ), + QueryFieldPredicate(field="count", values=("3",), op=">=").with_field_ref( + QueryFieldRef(scope="unit", name="count", source_name="count", unit="message") + ), + QueryNotPredicate(QueryFieldPredicate(field="origin", values=("codex-session",), op="=")), + QueryBoolPredicate( + "and", + ( + QueryFieldPredicate(field="origin", values=("codex-session",), op="="), + QueryFieldPredicate(field="repo", values=("polylogue",), op="="), + ), + ), + QueryBoolPredicate( + "or", + ( + QueryFieldPredicate(field="origin", values=("codex-session",), op="="), + QueryNotPredicate(QueryFieldPredicate(field="repo", values=("polylogue",), op="=")), + ), + ), + QueryExistsPredicate(unit="block", child=QueryFieldPredicate(field="tool_name", values=("Bash",), op="=")), + QuerySequencePredicate(action_terms=("plan", "edit", "test")), + QuerySequencePredicate( + steps=( + QueryFieldPredicate(field="action", values=("plan",), op="="), + QueryFieldPredicate(field="action", values=("edit",), op="="), + ), + constraints=(QuerySequenceConstraint(kind="within", within_ms=60_000),), + ), + QueryTextPredicate(text="deploy with caveats"), + QuerySemanticPredicate(text="deploy with caveats"), + QueryLineagePredicate(seed_session_id="codex-session:abc123"), +) + + +@pytest.mark.parametrize("predicate", _PREDICATE_ROUNDTRIP_CASES, ids=lambda p: type(p).__name__) +def test_predicate_ast_roundtrip_is_lossless(predicate: QueryPredicate) -> None: + ast = predicate_to_ast(predicate) + reconstructed = ast_to_predicate(ast) + assert reconstructed == predicate + # And the reconstructed predicate must re-validate to an equal AST node, + # not merely compare equal as a dataclass. + assert predicate_to_ast(reconstructed) == ast + + +_EXPRESSION_CASES: tuple[str, ...] = ( + 'repo:polylogue since:7d "json envelope"', + "sessions where exists block(type:code) AND lineage:id:root", + 'sessions where semantic:"query compiler" AND title:hit', + "sessions where seq(action:file_edit -> action:shell AND output:failed)", + "messages where role:assistant AND text:timeout", + "messages where role:assistant | sort by time desc | limit 2 | offset 3", + "sessions where repo:polylogue | messages where role:assistant | limit 5", + "from result-set:stable-set | group by model | count", + '{"repo": "polylogue", "limit": 5}', + "messages between 5 and 20", + "context-snapshots where session.repo:polylogue AND boundary:session_start", + "actions where action:file_edit AND path:polylogue", + "deploy failed today", +) + + +@pytest.mark.parametrize("expression", _EXPRESSION_CASES) +def test_explanation_payload_validates_against_canonical_ast(expression: str) -> None: + explanation = explain_expression(expression) + payload = explanation.to_payload() + + ast = explanation_payload_to_ast(payload) + + assert ast.schema_version == QUERY_AST_SCHEMA_VERSION + assert payload["schema_version"] == QUERY_AST_SCHEMA_VERSION + assert ast.source_text == expression + assert ast.lowerer == explanation.lowerer + + if explanation.predicate is not None: + assert ast.predicate is not None + # The typed predicate sub-tree round-trips back to the exact + # dataclass the parser produced, not just a structurally similar one. + assert ast_to_predicate(ast.predicate) == explanation.predicate + + +def test_canonical_ast_schema_is_json_schema_serializable() -> None: + """The schema powering ``devtools render openapi`` must build cleanly.""" + schema = QueryExpressionExplanationAst.model_json_schema(mode="serialization") + assert schema["title"] == "QueryExpressionExplanationAst" + # A representative nested predicate variant must be reachable from $defs + # so OpenAPI consumers can resolve the full recursive predicate tree. + defs = schema.get("$defs", {}) + assert "QueryBoolPredicateAst" in defs + assert "QueryFieldPredicateAst" in defs + + +def test_canonical_ast_rejects_unknown_top_level_key() -> None: + """Schema drift (an added/removed key) must fail loudly, not silently pass.""" + explanation = explain_expression("repo:polylogue") + payload = dict(explanation.to_payload()) + payload["unexpected_new_field"] = "surprise" + with pytest.raises(ValidationError, match="extra_forbidden|Extra inputs"): + explanation_payload_to_ast(payload) + + +def test_canonical_predicate_ast_rejects_unknown_kind() -> None: + adapter: TypeAdapter[object] = TypeAdapter(QueryPredicateAst) + with pytest.raises(ValidationError): + adapter.validate_python({"kind": "made-up"}) diff --git a/webui/src/api/generated.ts b/webui/src/api/generated.ts index 07ed5cfe29..5285cd21b7 100644 --- a/webui/src/api/generated.ts +++ b/webui/src/api/generated.ts @@ -234,6 +234,11 @@ export type ObservedEventQueryRowPayload = { readonly unit?: "observed-event"; }; +export type QueryBoolPredicateAst = { + readonly children?: ReadonlyArray; + readonly kind: "and" | "or"; +}; + export type QueryErrorPayload = { readonly detail?: string | null; readonly error: string; @@ -241,6 +246,80 @@ export type QueryErrorPayload = { readonly ok?: false; }; +export type QueryExistsPredicateAst = { + readonly child: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst; + readonly kind?: "exists"; + readonly unit: "message" | "action" | "block" | "assertion" | "file" | "run" | "observed-event" | "context-snapshot" | "delegation"; +}; + +export type QueryExpressionAstNodeAst = { + readonly clauses?: ReadonlyArray | null; + readonly entry: "json" | "reference_pipeline" | "unit_source" | "boolean" | "compact"; + readonly predicate?: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst | null; + readonly reference_pipeline?: ReferenceQueryPipelineAst | null; + readonly unit_source?: QueryUnitSourceAst | null; +}; + +export type QueryExpressionClauseAst = { + readonly field?: string | null; + readonly kind: "field" | "count" | "count_range" | "date" | "date_range" | "text" | "json"; + readonly max_number?: number | null; + readonly max_value?: string | null; + readonly min_number?: number | null; + readonly min_value?: string | null; + readonly negated?: boolean; + readonly number?: number | null; + readonly op?: "=" | ">" | ">=" | "<" | "<=" | null; + readonly quoted?: boolean; + readonly value?: string | null; +}; + +export type QueryExpressionExplanationAst = { + readonly ast?: QueryExpressionAstNodeAst | null; + readonly clauses?: ReadonlyArray; + readonly execution_legs?: ReadonlyArray; + readonly lowerer: string; + readonly lowering_plan?: QueryLoweringPlanAst | null; + readonly plan_description?: ReadonlyArray; + readonly predicate?: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst | null; + readonly schema_version?: "polylogue.query-explain-ast.v1"; + readonly selected_units?: ReadonlyArray; + readonly source_text: string; + readonly unsupported_nodes?: ReadonlyArray; +}; + +export type QueryFieldPredicateAst = { + readonly field: string; + readonly field_ref?: QueryFieldRefAst | null; + readonly kind?: "field"; + readonly op?: "=" | ">" | ">=" | "<" | "<="; + readonly values?: ReadonlyArray; +}; + +export type QueryFieldRefAst = { + readonly name: string; + readonly scope: "session" | "unit"; + readonly source_name: string; + readonly unit?: string | null; +}; + +export type QueryLineagePredicateAst = { + readonly kind?: "lineage"; + readonly seed_session_id: string; + readonly unit?: "session"; +}; + +export type QueryLoweringPlanAst = { + readonly compatibility_selector?: string | null; + readonly execution_legs?: ReadonlyArray; + readonly lowerer: string; + readonly pipeline?: QueryUnitPipelineAst | null; + readonly pipeline_stages?: ReadonlyArray | null; + readonly plan_description?: ReadonlyArray; + readonly reference_lineage?: ReadonlyArray | null; + readonly selected_units?: ReadonlyArray; +}; + export type QueryMissDiagnosticsPayload = { readonly archive_session_count?: number | null; readonly filters: ReadonlyArray; @@ -257,6 +336,36 @@ export type QueryMissReasonPayload = { readonly summary: string; }; +export type QueryNotPredicateAst = { + readonly child: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst; + readonly kind?: "not"; +}; + +export type QuerySemanticPredicateAst = { + readonly kind?: "semantic"; + readonly text: string; + readonly unit?: "session"; +}; + +export type QuerySequenceConstraintAst = { + readonly kind?: "ordered" | "next" | "within"; + readonly within_ms?: number | null; +}; + +export type QuerySequencePredicateAst = { + readonly actions?: ReadonlyArray; + readonly constraints?: ReadonlyArray; + readonly kind?: "sequence"; + readonly steps?: ReadonlyArray; + readonly unit?: "action"; +}; + +export type QueryTextPredicateAst = { + readonly kind?: "fts"; + readonly text: string; + readonly unit?: "session"; +}; + export type QueryUnitAggregateEnvelope = { readonly continuation?: string | null; readonly items: ReadonlyArray; @@ -292,6 +401,11 @@ export type QueryUnitAggregateRowPayload = { readonly unit: "message" | "action" | "block" | "assertion" | "file" | "run" | "observed-event" | "context-snapshot" | "delegation"; }; +export type QueryUnitCountStageAst = { + readonly kind?: "count"; + readonly metric?: "count"; +}; + export type QueryUnitEnvelope = { readonly continuation?: string | null; readonly items: ReadonlyArray; @@ -320,6 +434,86 @@ export type QueryUnitEnvelope = { readonly unit: "message" | "action" | "block" | "assertion" | "file" | "run" | "observed-event" | "context-snapshot" | "delegation"; }; +export type QueryUnitGroupStageAst = { + readonly field?: string | null; + readonly fields?: ReadonlyArray | null; + readonly kind?: "group"; +}; + +export type QueryUnitLimitStageAst = { + readonly kind?: "limit"; + readonly value: number; +}; + +export type QueryUnitOffsetStageAst = { + readonly kind?: "offset"; + readonly value: number; +}; + +export type QueryUnitPipelineAst = { + readonly result?: QueryUnitPipelineResultAst | null; + readonly session_scope?: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst | null; + readonly source: QueryUnitPipelineSourceAst; + readonly stages?: ReadonlyArray; +}; + +export type QueryUnitPipelineResultAst = { + readonly aggregate?: "count" | null; + readonly group_by?: string | null; + readonly limit?: number | null; + readonly offset?: number | null; + readonly sort?: QueryUnitSortSpecAst | null; +}; + +export type QueryUnitPipelineSourceAst = { + readonly predicate: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst; + readonly unit: "message" | "action" | "block" | "assertion" | "file" | "run" | "observed-event" | "context-snapshot" | "delegation"; +}; + +export type QueryUnitSessionScopeStageAst = { + readonly kind?: "session_scope"; + readonly predicate: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst; +}; + +export type QueryUnitSortSpecAst = { + readonly direction?: "asc" | "desc"; + readonly field: "time" | "count" | "key"; +}; + +export type QueryUnitSortStageAst = { + readonly kind?: "sort"; + readonly sort: QueryUnitSortSpecAst; +}; + +export type QueryUnitSourceAst = { + readonly aggregate?: "count" | null; + readonly group_by?: string | null; + readonly limit?: number | null; + readonly offset?: number | null; + readonly pipeline: QueryUnitPipelineAst; + readonly pipeline_stages?: ReadonlyArray; + readonly predicate: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst; + readonly session_predicate?: QueryFieldPredicateAst | QueryNotPredicateAst | QueryBoolPredicateAst | QueryExistsPredicateAst | QuerySequencePredicateAst | QueryTextPredicateAst | QuerySemanticPredicateAst | QueryLineagePredicateAst | null; + readonly sort?: QueryUnitSortSpecAst | null; + readonly unit: "message" | "action" | "block" | "assertion" | "file" | "run" | "observed-event" | "context-snapshot" | "delegation"; +}; + +export type QueryUnitTerminalStageAst = { + readonly action: string; + readonly args?: ({ + readonly [key: string]: string; +}) | null; + readonly kind?: "terminal"; +}; + +export type QueryUnitTransformStageAst = { + readonly args?: ({ + readonly [key: string]: string; +}) | null; + readonly kind?: "transform"; + readonly name: string; +}; + export type ReaderActionAvailabilityPayload = { readonly disabled_reason?: string | null; readonly enabled?: boolean; @@ -328,6 +522,19 @@ export type ReaderActionAvailabilityPayload = { readonly state?: "enabled" | "disabled" | "partial" | "dangerous" | "loading" | "target" | "unavailable"; }; +export type RefOperandAst = { + readonly evaluation_mode: "re-evaluate" | "retained" | "resolver-defined"; + readonly grain?: string | null; + readonly kind?: "ref_operand"; + readonly reference: string; + readonly reference_kind: string; +}; + +export type ReferenceQueryPipelineAst = { + readonly source: RefOperandAst; + readonly stages?: ReadonlyArray; +}; + export type RouteReadinessPayload = { readonly component?: string | null; readonly generated_at?: string | null;